This commit is contained in:
Michael J. Arcan 2026-05-25 00:27:09 +02:00 committed by GitHub
commit 929bd43b1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 21252 additions and 20 deletions

View file

@ -153,33 +153,27 @@ class Hetzner(HetznerAccount):
return True
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}/actions/set_records"
data = {
'records': [{
'value': str(address)
}]
}
response = requests.post(url, headers=headers, json=data)
if response.status_code not in [200, 201]:
"""Update existing record with new address
NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update.
Workaround: DELETE old record, then POST new record.
"""
# DELETE old record first
delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}"
delete_response = requests.delete(delete_url, headers=headers)
if delete_response.status_code not in [200, 201, 204]:
syslog.syslog(
syslog.LOG_ERR,
"Account %s error 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"""

8
net/hclouddns/Makefile Normal file
View file

@ -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"

168
net/hclouddns/deploy.sh Executable file
View file

@ -0,0 +1,168 @@
#!/bin/bash
#
# Deploy os-hcloud-ddns to OPNsense for testing
#
set -e
# Konfiguration
OPNSENSE_IP="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_DIR="${SCRIPT_DIR}/src"
# Farben
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
if [ -z "$OPNSENSE_IP" ]; then
echo -e "${YELLOW}Usage: $0 <opnsense-ip>${NC}"
echo ""
echo "Example:"
echo " $0 192.168.1.1"
echo " $0 opnsense.local"
exit 1
fi
# Determine SSH/SCP method: key-based or password
SSH_CMD="ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5"
SCP_CMD="scp -o StrictHostKeyChecking=no -q"
if ssh -o ConnectTimeout=5 -o BatchMode=yes root@${OPNSENSE_IP} "true" 2>/dev/null; then
AUTH_METHOD="key"
else
# No key auth available - use sshpass
if ! command -v sshpass &>/dev/null; then
echo -e "${RED}ERROR: No SSH key configured and 'sshpass' not installed.${NC}"
echo "Install sshpass or configure SSH key authentication."
exit 1
fi
# Use SSHPASS env var if already set, otherwise prompt
if [ -z "$SSHPASS" ]; then
read -s -p "root@${OPNSENSE_IP} password: " SSHPASS
echo ""
export SSHPASS
fi
SSH_CMD="sshpass -e ${SSH_CMD}"
SCP_CMD="sshpass -e ${SCP_CMD}"
AUTH_METHOD="password"
fi
echo -e "${GREEN}=== Deploying os-hcloud-ddns to ${OPNSENSE_IP} (${AUTH_METHOD} auth) ===${NC}"
echo ""
# Test SSH connection
echo -e "${YELLOW}[1/5] Testing SSH connection...${NC}"
if ! ${SSH_CMD} root@${OPNSENSE_IP} "echo 'SSH OK'" 2>/dev/null; then
echo -e "${RED}ERROR: Cannot connect to root@${OPNSENSE_IP}${NC}"
echo "Make sure:"
echo " 1. SSH is enabled on OPNsense"
echo " 2. Your SSH key or password is correct"
echo " 3. The IP address is correct"
exit 1
fi
# Clean up old plugin artifacts (renamed from HCloudDDNS to HCloudDNS in Dec 2025)
echo -e "${YELLOW}[2/6] Cleaning up old plugin artifacts (hcloudddns → hclouddns)...${NC}"
${SSH_CMD} root@${OPNSENSE_IP} "
rm -f /usr/local/etc/inc/plugins.inc.d/hcloudddns.inc
rm -f /usr/local/etc/rc.syshook.d/monitor/50-hcloudddns
rm -f /usr/local/etc/rc.syshook.d/carp/20-hcloudddns
rm -f /usr/local/opnsense/service/conf/actions.d/actions_hcloudddns.conf
rm -rf /usr/local/opnsense/scripts/HCloudDDNS
rm -rf /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDDNS
rm -rf /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDDNS
rm -rf /usr/local/opnsense/mvc/app/views/OPNsense/HCloudDDNS
"
# Create directories on OPNsense
echo -e "${YELLOW}[3/6] Creating directories...${NC}"
${SSH_CMD} root@${OPNSENSE_IP} "
mkdir -p /usr/local/opnsense/scripts/HCloudDNS/lib
mkdir -p /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api
mkdir -p /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms
mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL
mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu
mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations
mkdir -p /usr/local/opnsense/mvc/app/views/OPNsense/HCloudDNS
mkdir -p /usr/local/opnsense/service/conf/actions.d
mkdir -p /usr/local/etc/inc/plugins.inc.d
mkdir -p /usr/local/etc/rc.syshook.d/carp
mkdir -p /usr/local/etc/rc.syshook.d/monitor
"
# Copy files
echo -e "${YELLOW}[4/6] Copying files...${NC}"
# Python scripts
${SCP_CMD} ${SRC_DIR}/opnsense/scripts/HCloudDNS/*.py root@${OPNSENSE_IP}:/usr/local/opnsense/scripts/HCloudDNS/
${SCP_CMD} ${SRC_DIR}/opnsense/scripts/HCloudDNS/lib/*.py root@${OPNSENSE_IP}:/usr/local/opnsense/scripts/HCloudDNS/lib/
# PHP Controllers
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/
# Forms
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/
# Models
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/
# Views
${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/views/OPNsense/HCloudDNS/*.volt root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/views/OPNsense/HCloudDNS/
# Configd actions
${SCP_CMD} ${SRC_DIR}/opnsense/service/conf/actions.d/actions_hclouddns.conf root@${OPNSENSE_IP}:/usr/local/opnsense/service/conf/actions.d/
# Plugin hook
${SCP_CMD} ${SRC_DIR}/etc/inc/plugins.inc.d/hclouddns.inc root@${OPNSENSE_IP}:/usr/local/etc/inc/plugins.inc.d/
# Syshooks (gateway monitor + CARP transition)
${SCP_CMD} ${SRC_DIR}/etc/rc.syshook.d/monitor/50-hclouddns root@${OPNSENSE_IP}:/usr/local/etc/rc.syshook.d/monitor/
${SCP_CMD} ${SRC_DIR}/etc/rc.syshook.d/carp/20-hclouddns root@${OPNSENSE_IP}:/usr/local/etc/rc.syshook.d/carp/
# Syslog filter template (for Log File tab)
${SSH_CMD} root@${OPNSENSE_IP} "mkdir -p /usr/local/opnsense/service/templates/OPNsense/Syslog/local"
${SCP_CMD} ${SRC_DIR}/opnsense/service/templates/OPNsense/Syslog/local/hclouddns.conf root@${OPNSENSE_IP}:/usr/local/opnsense/service/templates/OPNsense/Syslog/local/
# Set permissions and restart services
echo -e "${YELLOW}[5/6] Setting permissions and restarting services...${NC}"
${SSH_CMD} root@${OPNSENSE_IP} "
chmod +x /usr/local/opnsense/scripts/HCloudDNS/*.py
chmod +x /usr/local/opnsense/scripts/HCloudDNS/lib/*.py
chmod +x /usr/local/etc/rc.syshook.d/carp/20-hclouddns
chmod +x /usr/local/etc/rc.syshook.d/monitor/50-hclouddns
service configd restart
# Regenerate syslog-ng config to include hclouddns filter and reload
configctl template reload OPNsense/Syslog
service syslog-ng restart
"
# Test
echo -e "${YELLOW}[6/6] Testing installation...${NC}"
RESULT=$(${SSH_CMD} root@${OPNSENSE_IP} "configctl hclouddns status 2>&1 || echo 'FAIL'")
if [[ "$RESULT" == *"FAIL"* ]] || [[ "$RESULT" == *"error"* ]]; then
echo -e "${RED}WARNING: configctl test returned unexpected result${NC}"
echo "$RESULT"
else
echo -e "${GREEN}configctl hclouddns status: OK${NC}"
fi
echo ""
echo -e "${GREEN}=== Deployment complete! ===${NC}"
echo ""
echo "Next steps:"
echo " 1. Open https://${OPNSENSE_IP} in your browser"
echo " 2. Navigate to: Services → Hetzner Cloud DDNS"
echo " 3. If menu doesn't appear, run on OPNsense:"
echo " service php-fpm restart"
echo ""
echo "To test the backend manually:"
echo " ssh root@${OPNSENSE_IP}"
echo " configctl hclouddns validate YOUR_HETZNER_TOKEN"
echo " configctl hclouddns list zones YOUR_HETZNER_TOKEN"

51
net/hclouddns/install.sh Executable file
View file

@ -0,0 +1,51 @@
#!/bin/sh
#
# HCloudDNS Plugin Installer for OPNsense
# Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
#
# Usage: ./install.sh [user@]hostname
#
set -e
TARGET="${1:-root@192.168.1.1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=========================================="
echo " HCloudDNS Plugin Installer v2.0.0"
echo "=========================================="
echo ""
echo "Target: $TARGET"
echo "Source: $SCRIPT_DIR/src"
echo ""
# Check if src directory exists
if [ ! -d "$SCRIPT_DIR/src" ]; then
echo "ERROR: src/ directory not found!"
exit 1
fi
echo ">>> Copying files to OPNsense..."
scp -r "$SCRIPT_DIR/src/"* "$TARGET:/usr/local/"
echo ""
echo ">>> Setting permissions..."
ssh "$TARGET" "chmod +x /usr/local/opnsense/scripts/HCloudDNS/*.py" 2>/dev/null || true
ssh "$TARGET" "chmod +x /usr/local/etc/rc.syshook.d/monitor/50-hclouddns" 2>/dev/null || true
echo ""
echo ">>> Restarting configd service..."
ssh "$TARGET" 'service configd restart'
echo ""
echo "=========================================="
echo " Installation complete!"
echo "=========================================="
echo ""
echo "Access the plugin at:"
echo " Services -> Hetzner Cloud DNS"
echo ""
echo "If menu doesn't appear, clear browser cache"
echo "or restart the web GUI:"
echo " service php-fpm restart"
echo ""

16
net/hclouddns/pkg-descr Normal file
View file

@ -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

53
net/hclouddns/pkg-plist Normal file
View file

@ -0,0 +1,53 @@
etc/inc/plugins.inc.d/hclouddns.inc
etc/rc.syshook.d/carp/20-hclouddns
etc/rc.syshook.d/monitor/50-hclouddns
opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php
opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php
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/service_control.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

View file

@ -0,0 +1,138 @@
<?php
/**
* 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.
*/
/**
* Register HCloudDNS services
* @return array
*/
function hclouddns_services()
{
$services = array();
$mdl = new \OPNsense\HCloudDNS\HCloudDNS();
if ((string)$mdl->general->enabled == '1') {
$services[] = array(
'description' => gettext('Hetzner Cloud Dynamic DNS'),
'configd' => array(
'start' => array('hclouddns start'),
'stop' => array('hclouddns stop'),
'restart' => array('hclouddns update'),
),
'name' => 'hclouddns',
'nocheck' => true,
);
}
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);
}

View file

@ -0,0 +1,51 @@
#!/bin/sh
#
# Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# HCloudDNS CARP Transition Syshook
# Called by OPNsense on CARP state transitions
# Arguments: $1 = interface, $2 = MASTER|BACKUP|INIT
SUBSYSTEM="carp"
TYPE="${1}"
STATE="${2}"
logger -t hclouddns "CARP transition: interface=${TYPE} state=${STATE}"
case "${STATE}" in
MASTER)
# This node became MASTER - trigger DNS update
# The Python script will verify CARP status again before updating
logger -t hclouddns "CARP MASTER on ${TYPE} - triggering DNS update"
/usr/local/sbin/configctl -d hclouddns update
;;
BACKUP|INIT)
logger -t hclouddns "CARP ${STATE} on ${TYPE} - no action"
;;
esac
exit 0

View file

@ -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

View file

@ -0,0 +1,215 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Base\UserException;
use OPNsense\Core\Backend;
/**
* Class AccountsController
* @package OPNsense\HCloudDNS\Api
*/
class AccountsController extends ApiMutableModelControllerBase
{
protected static $internalModelClass = '\OPNsense\HCloudDNS\HCloudDNS';
protected static $internalModelName = 'hclouddns';
/**
* Search accounts
* @return array
*/
public function searchItemAction()
{
return $this->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
];
}
}

View file

@ -0,0 +1,950 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Core\Backend;
class EntriesController extends ApiMutableModelControllerBase
{
protected static $internalModelClass = '\OPNsense\HCloudDNS\HCloudDNS';
protected static $internalModelName = 'hclouddns';
/**
* Search entries with live status data
* @return array search results
*/
public function searchItemAction()
{
// Get base search results, default sort by account then recordName
$result = $this->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,
'propagated' => $entryState['propagated'] ?? null
];
}
// 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
];
}
}

View file

@ -0,0 +1,178 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Core\Backend;
class GatewaysController extends ApiMutableModelControllerBase
{
protected static $internalModelClass = '\OPNsense\HCloudDNS\HCloudDNS';
protected static $internalModelName = 'hclouddns';
/**
* Search gateways
* @return array search results
*/
public function searchItemAction()
{
return $this->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,
'maintenance' => $gwState['maintenance'] ?? false,
'maintenanceScheduled' => (string)($gw->maintenanceScheduled ?? '0') === '1',
'maintenanceStart' => (string)($gw->maintenanceStart ?? ''),
'maintenanceEnd' => (string)($gw->maintenanceEnd ?? ''),
'lastCheck' => $gwState['lastCheck'] ?? 0
];
}
$result['lastUpdate'] = $state['lastUpdate'] ?? 0;
return $result;
}
}

View file

@ -0,0 +1,789 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
/**
* Class HetznerController
* Proxy for Hetzner Cloud API calls (validate, list zones, list records)
* @package OPNsense\HCloudDNS\Api
*/
class HetznerController extends ApiControllerBase
{
/**
* Validate API token
* @return array
*/
public function validateTokenAction()
{
$result = ['status' => '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 zone at Hetzner
* @return array
*/
public function createZoneAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$accountUuid = $this->request->getPost('account_uuid', 'string', '');
$zoneName = $this->request->getPost('zone_name', 'string', '');
if (empty($accountUuid) || empty($zoneName)) {
return ['status' => 'error', 'message' => 'Missing required parameters'];
}
$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'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$zoneName = strtolower(preg_replace('/[^a-zA-Z0-9.-]/', '', $zoneName));
$backend = new Backend();
$response = $backend->configdpRun('hclouddns dns createzone', [$token, $zoneName]);
$data = json_decode(trim($response), true);
if ($data !== null && isset($data['status']) && $data['status'] === 'ok') {
return [
'status' => 'ok',
'message' => $data['message'] ?? "Zone $zoneName created",
'zone_id' => $data['zone_id'] ?? '',
'zone_name' => $data['zone_name'] ?? $zoneName
];
}
return [
'status' => 'error',
'message' => $data['message'] ?? 'Failed to create zone'
];
}
/**
* 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'];
}
/**
* Export zone in BIND format
* @return array
*/
public function zoneExportAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$accountUuid = $this->request->getPost('account_uuid', 'string', '');
$zoneId = $this->request->getPost('zone_id', 'string', '');
if (empty($accountUuid) || empty($zoneId)) {
return ['status' => 'error', 'message' => 'Missing required parameters'];
}
$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'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
$backend = new Backend();
$response = $backend->configdpRun('hclouddns dns export', [$token, $zoneId]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Export failed'];
}
/**
* Parse imported zonefile
* @return array
*/
public function zoneImportParseAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$content = $this->request->getPost('content', 'string', '');
if (empty($content)) {
return ['status' => 'error', 'message' => 'No zonefile content provided'];
}
// Parse via Python script using stdin
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w']
];
$cmd = '/usr/local/opnsense/scripts/HCloudDNS/zone_import.py';
$process = proc_open($cmd, $descriptorspec, $pipes);
if (is_resource($process)) {
fwrite($pipes[0], $content);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$data = json_decode(trim($output), true);
if ($data !== null) {
return $data;
}
}
return ['status' => 'error', 'message' => 'Import parse failed'];
}
/**
* DNS Health Check for a zone
* @return array
*/
public function dnsHealthCheckAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$accountUuid = $this->request->getPost('account_uuid', 'string', '');
$zoneId = $this->request->getPost('zone_id', 'string', '');
$zoneName = $this->request->getPost('zone_name', 'string', '');
if (empty($accountUuid) || empty($zoneId)) {
return ['status' => 'error', 'message' => 'Missing required parameters'];
}
$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'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
$zoneName = preg_replace('/[^a-zA-Z0-9._-]/', '', $zoneName);
$backend = new Backend();
$response = $backend->configdpRun('hclouddns dns healthcheck', [
$token, $zoneId, $zoneName
]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Health check failed'];
}
/**
* DNSSEC Status Check for a zone
* @return array
*/
public function dnssecStatusAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$accountUuid = $this->request->getPost('account_uuid', 'string', '');
$zoneName = $this->request->getPost('zone_name', 'string', '');
if (empty($accountUuid) || empty($zoneName)) {
return ['status' => 'error', 'message' => 'Missing required parameters'];
}
$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'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$zoneName = preg_replace('/[^a-zA-Z0-9._-]/', '', $zoneName);
$backend = new Backend();
$response = $backend->configdpRun('hclouddns dns dnssec', [
$token, $zoneName
]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'DNSSEC check failed'];
}
/**
* Zone Propagation Check
* @return array
*/
public function zonePropagationCheckAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$accountUuid = $this->request->getPost('account_uuid', 'string', '');
$zoneId = $this->request->getPost('zone_id', 'string', '');
if (empty($accountUuid) || empty($zoneId)) {
return ['status' => 'error', 'message' => 'Missing required parameters'];
}
$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'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
$backend = new Backend();
$response = $backend->configdpRun('hclouddns dns propagation', [
$token, $zoneId
]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Propagation check failed'];
}
}

View file

@ -0,0 +1,301 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\HCloudDNS\HCloudDNS;
/**
* Class HistoryController
* @package OPNsense\HCloudDNS\Api
*/
class HistoryController extends ApiControllerBase
{
private static $historyFile = '/var/log/hclouddns/history.jsonl';
/**
* Add a history entry (called from HetznerController after DNS changes)
*/
public static function addEntry(
$action,
$accountUuid,
$accountName,
$zoneId,
$zoneName,
$recordName,
$recordType,
$oldValue,
$oldTtl,
$newValue,
$newTtl
) {
$dir = dirname(self::$historyFile);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
$entry = [
'uuid' => sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
),
'timestamp' => time(),
'action' => $action,
'accountUuid' => $accountUuid,
'accountName' => $accountName,
'zoneId' => $zoneId,
'zoneName' => $zoneName,
'recordName' => $recordName,
'recordType' => $recordType,
'oldValue' => $oldValue,
'oldTtl' => intval($oldTtl),
'newValue' => $newValue,
'newTtl' => intval($newTtl),
'reverted' => false
];
$line = json_encode($entry) . "\n";
$fp = @fopen(self::$historyFile, 'a');
if ($fp) {
flock($fp, LOCK_EX);
fwrite($fp, $line);
flock($fp, LOCK_UN);
fclose($fp);
@chmod(self::$historyFile, 0600);
}
}
/**
* Get aggregated history statistics
* @return array
*/
public function statsAction()
{
$days = $this->request->getPost('days', 'int', 30);
if ($days < 1) {
$days = 30;
}
if ($days > 365) {
$days = 365;
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns history stats', [strval($days)]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Failed to get stats'];
}
/**
* Search history entries (from JSONL via configd)
* @return array
*/
public function searchItemAction()
{
$backend = new Backend();
$response = $backend->configdRun('hclouddns history search');
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return [
'rows' => [],
'rowCount' => 0,
'total' => 0,
'current' => 1
];
}
/**
* Get a single history entry
* @param string $uuid
* @return array
*/
public function getItemAction($uuid)
{
if (empty($uuid) || !preg_match('/^[a-f0-9-]{36}$/', $uuid)) {
return ['status' => 'error', 'message' => 'Invalid UUID'];
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns history get', [$uuid]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'History entry not found'];
}
/**
* 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'];
}
if (empty($uuid) || !preg_match('/^[a-f0-9-]{36}$/', $uuid)) {
return ['status' => 'error', 'message' => 'Invalid UUID'];
}
// Get the history entry details
$backend = new Backend();
$response = $backend->configdpRun('hclouddns history get', [$uuid]);
$data = json_decode(trim($response), true);
if ($data === null || $data['status'] !== 'ok' || !isset($data['change'])) {
return ['status' => 'error', 'message' => 'History entry not found'];
}
$change = $data['change'];
if ($change['reverted'] === '1') {
return ['status' => 'error', 'message' => 'This change has already been reverted'];
}
$action = $change['action'];
$accountUuid = $change['accountUuid'];
$zoneId = $change['zoneId'];
$recordName = $change['recordName'];
$recordType = $change['recordType'];
$oldValue = $change['oldValue'];
$oldTtl = $change['oldTtl'] ?? '300';
// Get the account's API token
$mdl = new HCloudDNS();
$accountNode = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
if ($accountNode === null) {
return ['status' => 'error', 'message' => 'Account not found - cannot revert'];
}
$token = (string)$accountNode->apiToken;
if (empty($token)) {
return ['status' => 'error', 'message' => 'Account has no API token'];
}
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
$result = null;
// Perform the reverse action
if ($action === 'create') {
$response = $backend->configdpRun('hclouddns dns delete', [
$token, $zoneId, $recordName, $recordType
]);
$result = json_decode(trim($response), true);
} elseif ($action === 'delete') {
$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') {
$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 via configd
$backend->configdpRun('hclouddns history revert', [$uuid]);
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 = new HCloudDNS();
$retentionDays = (string)$mdl->general->historyRetentionDays ?: '7';
$backend = new Backend();
$response = $backend->configdpRun('hclouddns history cleanup', [$retentionDays]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Cleanup failed'];
}
/**
* Clear all history entries
* @return array
*/
public function clearAllAction()
{
if (!$this->request->isPost()) {
return ['status' => 'error', 'message' => 'POST required'];
}
$backend = new Backend();
$response = $backend->configdRun('hclouddns history clear');
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Clear failed'];
}
}

View file

@ -0,0 +1,481 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\HCloudDNS\HCloudDNS;
/**
* Class ServiceController
* @package OPNsense\HCloudDNS\Api
*/
class ServiceController extends ApiControllerBase
{
/**
* Get service status
* @return array
*/
public function statusAction()
{
$backend = new Backend();
$response = $backend->configdRun('hclouddns status');
$data = json_decode($response, true);
if ($data === null) {
return ['status' => 'error', 'message' => 'Failed to get status'];
}
// Determine running/stopped for updateServiceControlUI
$mdl = new HCloudDNS();
$enabled = (string)$mdl->general->enabled === '1';
$stopped = file_exists('/var/run/hclouddns.stopped');
$data['status'] = ($enabled && !$stopped) ? 'running' : 'stopped';
return $data;
}
/**
* Start service
* @return array
*/
public function startAction()
{
if ($this->request->isPost()) {
@unlink('/var/run/hclouddns.stopped');
$backend = new Backend();
$response = $backend->configdRun('hclouddns start');
return ['status' => 'ok'];
}
return ['status' => 'error', 'message' => 'POST request required'];
}
/**
* Stop service
* @return array
*/
public function stopAction()
{
if ($this->request->isPost()) {
$backend = new Backend();
$response = $backend->configdRun('hclouddns stop');
return ['status' => 'ok'];
}
return ['status' => 'error', 'message' => 'POST request required'];
}
/**
* Restart service
* @return array
*/
public function restartAction()
{
if ($this->request->isPost()) {
@unlink('/var/run/hclouddns.stopped');
$backend = new Backend();
$response = $backend->configdRun('hclouddns update');
return ['status' => 'ok'];
}
return ['status' => 'error', 'message' => 'POST request required'];
}
/**
* Get list of CARP VIPs from system config
* @return array
*/
public function getVipListAction()
{
$result = ['status' => 'ok', 'rows' => []];
$config = \OPNsense\Core\Config::getInstance()->object();
if (isset($config->virtualip) && isset($config->virtualip->vip)) {
foreach ($config->virtualip->vip as $vip) {
if ((string)$vip->mode === 'carp') {
$result['rows'][] = [
'vhid' => (string)$vip->vhid,
'subnet' => (string)$vip->subnet,
'interface' => (string)$vip->interface,
'descr' => (string)$vip->descr
];
}
}
}
return $result;
}
/**
* Trigger manual update
* @return array
*/
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 update');
$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'];
}
/**
* Preview DNS changes (dry run)
* @return array
*/
public function previewAction()
{
if ($this->request->isPost()) {
$backend = new Backend();
$response = $backend->configdRun('hclouddns dryrun');
$data = json_decode($response, true);
if ($data === null) {
return ['status' => 'error', 'message' => 'Preview failed'];
}
return $data;
}
return ['status' => 'error', 'message' => 'POST request required'];
}
/**
* Get failover history
* @return array
*/
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' => []]];
}
/**
* Start maintenance mode for a gateway
* @param string $uuid gateway UUID
* @return array
*/
public function maintenanceStartAction($uuid = null)
{
if ($this->request->isPost() && $uuid !== null) {
$mdl = new HCloudDNS();
$node = $mdl->getNodeByReference('gateways.gateway.' . $uuid);
if ($node === null) {
return ['status' => 'error', 'message' => 'Gateway not found'];
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns maintenance start', [$uuid]);
$data = json_decode(trim($response), true);
// Return immediately - DNS update is triggered by the frontend separately
return $data ?? ['status' => 'error', 'message' => 'Failed to start maintenance'];
}
return ['status' => 'error', 'message' => 'POST request with gateway UUID required'];
}
/**
* Stop maintenance mode for a gateway
* @param string $uuid gateway UUID
* @return array
*/
public function maintenanceStopAction($uuid = null)
{
if ($this->request->isPost() && $uuid !== null) {
$mdl = new HCloudDNS();
$node = $mdl->getNodeByReference('gateways.gateway.' . $uuid);
if ($node === null) {
return ['status' => 'error', 'message' => 'Gateway not found'];
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns maintenance stop', [$uuid]);
$data = json_decode(trim($response), true);
// Return immediately - DNS update is triggered by the frontend separately
return $data ?? ['status' => 'error', 'message' => 'Failed to stop maintenance'];
}
return ['status' => 'error', 'message' => 'POST request with gateway UUID required'];
}
/**
* Schedule maintenance window for a gateway
* @param string $uuid gateway UUID
* @return array
*/
public function maintenanceScheduleAction($uuid = null)
{
if ($this->request->isPost() && $uuid !== null) {
$mdl = new HCloudDNS();
$node = $mdl->getNodeByReference('gateways.gateway.' . $uuid);
if ($node === null) {
return ['status' => 'error', 'message' => 'Gateway not found'];
}
$start = $this->request->getPost('start', 'string', '');
$end = $this->request->getPost('end', 'string', '');
if (empty($start) || empty($end)) {
return ['status' => 'error', 'message' => 'Start and end datetime required'];
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns maintenance schedule', [$uuid, $start, $end]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Failed to schedule maintenance'];
}
return ['status' => 'error', 'message' => 'POST request with gateway UUID required'];
}
/**
* Check DNS propagation for a specific entry
* @param string $uuid entry UUID
* @return array propagation check result
*/
public function propagationCheckAction($uuid = null)
{
if ($uuid === null) {
return ['status' => 'error', 'message' => 'Entry UUID required'];
}
$mdl = new HCloudDNS();
$node = $mdl->getNodeByReference('entries.entry.' . $uuid);
if ($node === null) {
return ['status' => 'error', 'message' => 'Entry not found'];
}
$recordName = (string)$node->recordName;
$zoneName = (string)$node->zoneName;
$recordType = (string)$node->recordType;
// Get current IP from runtime state
$stateFile = '/var/run/hclouddns_state.json';
$currentIp = '';
if (file_exists($stateFile)) {
$state = json_decode(file_get_contents($stateFile), true) ?? [];
$currentIp = $state['entries'][$uuid]['hetznerIp'] ?? '';
}
if (empty($currentIp)) {
$currentIp = (string)$node->currentIp;
}
if (empty($currentIp)) {
return ['status' => 'error', 'message' => 'No current IP known for this entry'];
}
$backend = new Backend();
$response = $backend->configdpRun('hclouddns propagation check', [
$recordName, $zoneName, $recordType, $currentIp
]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Propagation check failed'];
}
/**
* Test notification channels
* @param string $channel Optional: email, webhook, ntfy (empty = all)
* @return array
*/
public function testNotifyAction($channel = '')
{
if ($this->request->isPost()) {
$backend = new Backend();
$validChannels = ['email', 'webhook', 'ntfy', ''];
if (!in_array($channel, $validChannels)) {
return ['status' => 'error', 'message' => 'Invalid channel'];
}
$response = $backend->configdpRun('hclouddns testnotify', [$channel]);
$data = json_decode(trim($response), true);
if ($data !== null) {
return $data;
}
return ['status' => 'error', 'message' => 'Test notification failed'];
}
return ['status' => 'error', 'message' => 'POST request required'];
}
}

View file

@ -0,0 +1,624 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
/**
* Class SettingsController
* @package OPNsense\HCloudDNS\Api
*/
class SettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelClass = '\OPNsense\HCloudDNS\HCloudDNS';
protected static $internalModelName = 'hclouddns';
/**
* Ensure notifications section exists in config with defaults
*/
private function ensureNotificationsExist()
{
$config = \OPNsense\Core\Config::getInstance()->object();
// Make sure HCloudDNS exists
if (!isset($config->OPNsense)) {
return;
}
if (!isset($config->OPNsense->HCloudDNS)) {
return;
}
$hcloud = $config->OPNsense->HCloudDNS;
// Add notifications section if missing
if (!isset($hcloud->notifications)) {
$hcloud->addChild('notifications');
$hcloud->notifications->addChild('enabled', '0');
$hcloud->notifications->addChild('notifyOnUpdate', '1');
$hcloud->notifications->addChild('notifyOnFailover', '1');
$hcloud->notifications->addChild('notifyOnFailback', '1');
$hcloud->notifications->addChild('notifyOnError', '1');
$hcloud->notifications->addChild('emailEnabled', '0');
$hcloud->notifications->addChild('emailTo', '');
$hcloud->notifications->addChild('emailFrom', '');
$hcloud->notifications->addChild('smtpServer', '');
$hcloud->notifications->addChild('smtpPort', '587');
$hcloud->notifications->addChild('smtpTls', 'starttls');
$hcloud->notifications->addChild('smtpUser', '');
$hcloud->notifications->addChild('smtpPassword', '');
$hcloud->notifications->addChild('webhookEnabled', '0');
$hcloud->notifications->addChild('webhookUrl', '');
$hcloud->notifications->addChild('webhookMethod', 'POST');
$hcloud->notifications->addChild('webhookSecret', '');
$hcloud->notifications->addChild('ntfyEnabled', '0');
$hcloud->notifications->addChild('ntfyServer', 'https://ntfy.sh');
$hcloud->notifications->addChild('ntfyTopic', '');
$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,
'carpAware' => (string)$general->carpAware,
'carpVhid' => (string)$general->carpVhid
];
// 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,
'emailFrom' => (string)$notifications->emailFrom,
'smtpServer' => (string)$notifications->smtpServer,
'smtpPort' => (string)$notifications->smtpPort,
'smtpTls' => (string)$notifications->smtpTls,
'smtpUser' => (string)$notifications->smtpUser,
'webhookEnabled' => (string)$notifications->webhookEnabled,
'webhookUrl' => (string)$notifications->webhookUrl,
'webhookMethod' => (string)$notifications->webhookMethod,
'webhookSecret' => (string)$notifications->webhookSecret,
'ntfyEnabled' => (string)$notifications->ntfyEnabled,
'ntfyServer' => (string)$notifications->ntfyServer,
'ntfyTopic' => (string)$notifications->ntfyTopic,
'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'];
if (isset($gen['carpAware'])) $mdl->general->carpAware = $gen['carpAware'];
if (isset($gen['carpVhid'])) $mdl->general->carpVhid = $gen['carpVhid'];
}
// 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['emailFrom'])) $mdl->notifications->emailFrom = $notif['emailFrom'];
if (isset($notif['smtpServer'])) $mdl->notifications->smtpServer = $notif['smtpServer'];
if (isset($notif['smtpPort'])) $mdl->notifications->smtpPort = $notif['smtpPort'];
if (isset($notif['smtpTls'])) $mdl->notifications->smtpTls = $notif['smtpTls'];
if (isset($notif['smtpUser'])) $mdl->notifications->smtpUser = $notif['smtpUser'];
if (isset($notif['smtpPassword'])) $mdl->notifications->smtpPassword = $notif['smtpPassword'];
if (isset($notif['webhookEnabled'])) $mdl->notifications->webhookEnabled = $notif['webhookEnabled'];
if (isset($notif['webhookUrl'])) $mdl->notifications->webhookUrl = $notif['webhookUrl'];
if (isset($notif['webhookMethod'])) $mdl->notifications->webhookMethod = $notif['webhookMethod'];
if (isset($notif['webhookSecret'])) $mdl->notifications->webhookSecret = $notif['webhookSecret'];
if (isset($notif['ntfyEnabled'])) $mdl->notifications->ntfyEnabled = $notif['ntfyEnabled'];
if (isset($notif['ntfyServer'])) $mdl->notifications->ntfyServer = $notif['ntfyServer'];
if (isset($notif['ntfyTopic'])) $mdl->notifications->ntfyTopic = $notif['ntfyTopic'];
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'] ?? []
];
}
}

View file

@ -0,0 +1,46 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS;
use OPNsense\Base\IndexController;
/**
* Class DnsController
* @package OPNsense\HCloudDNS
*/
class DnsController extends IndexController
{
/**
* Full DNS Management page - manage all zones and record types
*/
public function indexAction()
{
$this->view->pick('OPNsense/HCloudDNS/dns');
}
}

View file

@ -0,0 +1,46 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS;
use OPNsense\Base\IndexController;
/**
* Class HistoryController
* @package OPNsense\HCloudDNS
*/
class HistoryController extends IndexController
{
/**
* DNS Change History page
*/
public function indexAction()
{
$this->view->pick('OPNsense/HCloudDNS/history');
}
}

View file

@ -0,0 +1,105 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS;
use OPNsense\Base\IndexController as BaseIndexController;
/**
* Class IndexController
* @package OPNsense\HCloudDNS
*/
class IndexController extends BaseIndexController
{
/**
* Main page with tabbed interface (v2)
*/
public function indexAction()
{
$this->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');
$this->view->dyndnsSettingsForm = $this->getForm('dyndnsSettings');
}
/**
* 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');
}
/**
* 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');
}
}

View file

@ -0,0 +1,48 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS;
use OPNsense\Base\IndexController;
/**
* Class SettingsController
* @package OPNsense\HCloudDNS
*/
class SettingsController extends IndexController
{
/**
* Settings page - Accounts and general configuration
*/
public function indexAction()
{
$this->view->generalForm = $this->getForm('general');
$this->view->accountForm = $this->getForm('dialogAccount');
$this->view->pick('OPNsense/HCloudDNS/settings');
}
}

View file

@ -0,0 +1,32 @@
<form>
<field>
<id>account.enabled</id>
<label>Enabled</label>
<type>checkbox</type>
<help>Enable this API token</help>
</field>
<field>
<id>account.name</id>
<label>Name</label>
<type>text</type>
<help>Short name for this token (e.g. "Production", "Project A")</help>
</field>
<field>
<id>account.description</id>
<label>Description</label>
<type>text</type>
<help>Optional description</help>
</field>
<field>
<id>account.apiType</id>
<label>API Type</label>
<type>dropdown</type>
<help>Cloud API for new zones, Legacy API for zones not yet migrated</help>
</field>
<field>
<id>account.apiToken</id>
<label>API Token</label>
<type>password</type>
<help>Hetzner API Token</help>
</field>
</form>

View file

@ -0,0 +1,71 @@
<form>
<field>
<id>entry.enabled</id>
<label>Enabled</label>
<type>checkbox</type>
<help>Enable this DNS entry for dynamic updates</help>
</field>
<field>
<id>entry.account</id>
<label>Account</label>
<type>dropdown</type>
<help>API token/account to use for this entry</help>
</field>
<field>
<id>entry.zoneId</id>
<label>Zone</label>
<type>dropdown</type>
<help>Select the DNS zone for this record</help>
</field>
<field>
<id>entry.zoneName</id>
<label>Zone Name</label>
<type>hidden</type>
</field>
<field>
<id>entry.recordName</id>
<label>Record Name</label>
<type>text</type>
<help>DNS record name (@ for root, www, mail, etc.)</help>
</field>
<field>
<id>entry.recordType</id>
<label>Record Type</label>
<type>dropdown</type>
<help>A for IPv4, AAAA for IPv6</help>
</field>
<field>
<id>entry.primaryGateway</id>
<label>Primary Gateway</label>
<type>dropdown</type>
<help>Main gateway to use for this record's IP</help>
</field>
<field>
<id>entry.failoverGateway</id>
<label>Failover Gateway</label>
<type>dropdown</type>
<help>Backup gateway when primary is down (optional)</help>
</field>
<field>
<id>entry.ttl</id>
<label>TTL</label>
<type>dropdown</type>
<help>Time to live - use 60s for fast DynDNS updates</help>
</field>
<field>
<type>header</type>
<label>Status (read-only)</label>
</field>
<field>
<id>entry.currentIp</id>
<label>Current IP</label>
<type>info</type>
<help>Currently configured IP at Hetzner</help>
</field>
<field>
<id>entry.status</id>
<label>Status</label>
<type>info</type>
<help>Current status of this entry</help>
</field>
</form>

View file

@ -0,0 +1,12 @@
<form>
<field>
<type>header</type>
<label>DynDNS Settings</label>
</field>
<field>
<id>hclouddns.general.defaultTtl</id>
<label>Default TTL</label>
<type>dropdown</type>
<help>Default Time-To-Live for new DynDNS entries. 60s recommended for dynamic IPs.</help>
</field>
</form>

View file

@ -0,0 +1,38 @@
<form>
<field>
<id>gateway.enabled</id>
<label>Enabled</label>
<type>checkbox</type>
<help>Enable this gateway for DNS updates</help>
</field>
<field>
<id>gateway.name</id>
<label>Name</label>
<type>text</type>
<help>Friendly name for this gateway (e.g., "Glasfaser", "Kabel")</help>
</field>
<field>
<id>gateway.interface</id>
<label>Interface</label>
<type>dropdown</type>
<help>WAN interface for this gateway</help>
</field>
<field>
<id>gateway.priority</id>
<label>Priority</label>
<type>text</type>
<help>Gateway priority (1-100, lower = higher priority)</help>
</field>
<field>
<id>gateway.checkipMethod</id>
<label>IP Detection Method</label>
<type>dropdown</type>
<help>How to determine the public IP for this gateway</help>
</field>
<field>
<id>gateway.healthCheckTarget</id>
<label>Health Check Target</label>
<type>text</type>
<help>IP or hostname to ping for health checks (default: 8.8.8.8)</help>
</field>
</form>

View file

@ -0,0 +1,34 @@
<form>
<field>
<type>header</type>
<label>Scheduled Job Settings</label>
</field>
<field>
<id>hclouddns.general.cronEnabled</id>
<label>Enable Scheduled Updates</label>
<type>checkbox</type>
<help>Enable periodic DNS updates via cron job. Disabled by default - automatic triggers (gateway events, IP changes) are usually sufficient.</help>
</field>
<field>
<id>hclouddns.general.cronInterval</id>
<label>Update Interval (minutes)</label>
<type>text</type>
<help>How often to run the update check. Default: 5 minutes. Range: 1-60 minutes.</help>
</field>
<field>
<type>header</type>
<label>Update Behavior</label>
</field>
<field>
<id>hclouddns.general.checkInterval</id>
<label>IP Change Check Interval (seconds)</label>
<type>text</type>
<help>Minimum time between IP checks during scheduled updates. Default: 300 (5 minutes). Range: 60-86400</help>
</field>
<field>
<id>hclouddns.general.forceInterval</id>
<label>Force Update (days)</label>
<type>text</type>
<help>Force DNS update even if IP unchanged. 0 = disabled. Default: 0. Range: 0-30</help>
</field>
</form>

View file

@ -0,0 +1,18 @@
<form>
<field>
<type>header</type>
<label>High Availability (CARP)</label>
</field>
<field>
<id>hclouddns.general.carpAware</id>
<label>CARP Aware</label>
<type>checkbox</type>
<help>Only run DNS updates on the CARP master node. When enabled, the backup node will skip all DNS operations. Standalone systems without CARP interfaces are not affected (fail-open).</help>
</field>
<field>
<id>hclouddns.general.carpVhid</id>
<label>CARP VIP</label>
<type>dropdown</type>
<help>Select a CARP VIP to monitor, or "Any" to monitor all CARP interfaces. If any monitored VIP is BACKUP, DNS updates are skipped.</help>
</field>
</form>

View file

@ -0,0 +1,24 @@
<form>
<field>
<type>header</type>
<label>Failover Settings</label>
</field>
<field>
<id>hclouddns.general.failoverEnabled</id>
<label>Enable Failover</label>
<type>checkbox</type>
<help>Automatically switch DNS to backup gateway when primary fails (detected by OPNsense dpinger)</help>
</field>
<field>
<id>hclouddns.general.failbackEnabled</id>
<label>Enable Failback</label>
<type>checkbox</type>
<help>Automatically switch back to primary gateway when it becomes available again</help>
</field>
<field>
<id>hclouddns.general.failbackDelay</id>
<label>Failback Delay (seconds)</label>
<type>text</type>
<help>Wait time before failback after primary gateway becomes available. Default: 60. Range: 0-600</help>
</field>
</form>

View file

@ -0,0 +1,48 @@
<form>
<field>
<id>hclouddns.general.enabled</id>
<label>Enable Service</label>
<type>checkbox</type>
<help>Enable Hetzner Cloud Dynamic DNS Service</help>
</field>
<field>
<id>hclouddns.general.verbose</id>
<label>Verbose Logging</label>
<type>checkbox</type>
<help>Write detailed log entries to syslog</help>
</field>
<field>
<id>hclouddns.general.forceInterval</id>
<label>Force Update Interval (days)</label>
<type>text</type>
<help>Force DNS update even if IP unchanged after this many days. 0 = disabled (default: 0)</help>
</field>
<field>
<id>hclouddns.general.historyRetentionDays</id>
<label>History Retention (days)</label>
<type>text</type>
<help>Number of days to keep DNS change history for undo functionality (1-365, default: 7)</help>
</field>
<field>
<type>header</type>
<label>DNS Propagation Check</label>
</field>
<field>
<id>hclouddns.general.propagationCheck</id>
<label>Enable Propagation Check</label>
<type>checkbox</type>
<help>After updating DNS, verify the record propagated to Hetzner's authoritative nameservers</help>
</field>
<field>
<id>hclouddns.general.propagationRetries</id>
<label>Propagation Retries</label>
<type>text</type>
<help>Number of attempts to verify DNS propagation (1-10, default: 3)</help>
</field>
<field>
<id>hclouddns.general.propagationDelay</id>
<label>Propagation Delay (seconds)</label>
<type>text</type>
<help>Seconds between propagation check retries (1-30, default: 2)</help>
</field>
</form>

View file

@ -0,0 +1,16 @@
<acl>
<page-services-hclouddns>
<name>Services: Hetzner Cloud DNS</name>
<patterns>
<pattern>ui/hclouddns/*</pattern>
<pattern>api/hclouddns/*</pattern>
</patterns>
</page-services-hclouddns>
<page-services-hclouddns-history>
<name>Services: Hetzner Cloud DNS: History</name>
<patterns>
<pattern>ui/hclouddns/history</pattern>
<pattern>api/hclouddns/history/*</pattern>
</patterns>
</page-services-hclouddns-history>
</acl>

View file

@ -0,0 +1,39 @@
<?php
/**
* 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.
*/
namespace OPNsense\HCloudDNS;
use OPNsense\Base\BaseModel;
/**
* Class HCloudDNS
* @package OPNsense\HCloudDNS
*/
class HCloudDNS extends BaseModel
{
}

View file

@ -0,0 +1,396 @@
<model>
<mount>//OPNsense/HCloudDNS</mount>
<description>Hetzner Cloud Dynamic DNS with Multi-Zone and Failover</description>
<version>2.1.0</version>
<items>
<!-- Global Settings -->
<general>
<enabled type="BooleanField">
<default>0</default>
<Required>Y</Required>
</enabled>
<checkInterval type="IntegerField">
<default>300</default>
<MinimumValue>60</MinimumValue>
<MaximumValue>86400</MaximumValue>
<Required>Y</Required>
<ValidationMessage>Check interval must be between 60 and 86400 seconds</ValidationMessage>
</checkInterval>
<forceInterval type="IntegerField">
<default>0</default>
<MinimumValue>0</MinimumValue>
<MaximumValue>30</MaximumValue>
<ValidationMessage>Force interval must be between 0 and 30 days (0 = disabled)</ValidationMessage>
</forceInterval>
<verbose type="BooleanField">
<default>0</default>
</verbose>
<!-- Failover Settings -->
<failoverEnabled type="BooleanField">
<default>0</default>
</failoverEnabled>
<failbackEnabled type="BooleanField">
<default>1</default>
</failbackEnabled>
<failbackDelay type="IntegerField">
<default>60</default>
<MinimumValue>0</MinimumValue>
<MaximumValue>600</MaximumValue>
<ValidationMessage>Failback delay must be between 0 and 600 seconds</ValidationMessage>
</failbackDelay>
<!-- Scheduled Updates (Cron) Settings -->
<cronEnabled type="BooleanField">
<default>0</default>
</cronEnabled>
<cronInterval type="IntegerField">
<default>5</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>60</MaximumValue>
<ValidationMessage>Cron interval must be between 1 and 60 minutes</ValidationMessage>
</cronInterval>
<!-- History Settings -->
<historyRetentionDays type="IntegerField">
<default>7</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>365</MaximumValue>
<ValidationMessage>History retention must be between 1 and 365 days</ValidationMessage>
</historyRetentionDays>
<!-- Default TTL for DynDNS entries -->
<defaultTtl type="OptionField">
<Required>Y</Required>
<default>_60</default>
<OptionValues>
<_60>60s (1 min - DynDNS)</_60>
<_120>120s (2 min)</_120>
<_300>300s (5 min)</_300>
<_600>600s (10 min)</_600>
<_1800>1800s (30 min)</_1800>
<_3600>3600s (1 hour)</_3600>
<_86400>86400s (1 day)</_86400>
</OptionValues>
</defaultTtl>
<!-- High Availability (CARP) -->
<carpAware type="BooleanField">
<default>0</default>
</carpAware>
<carpVhid type="TextField">
<Required>N</Required>
<Mask>/^(\d{1,3})?$/</Mask>
<ValidationMessage>VHID must be a number (1-255) or empty for all</ValidationMessage>
</carpVhid>
<!-- DNS Propagation Check -->
<propagationCheck type="BooleanField">
<default>1</default>
</propagationCheck>
<propagationRetries type="IntegerField">
<default>3</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>10</MaximumValue>
<ValidationMessage>Propagation retries must be between 1 and 10</ValidationMessage>
</propagationRetries>
<propagationDelay type="IntegerField">
<default>2</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>30</MaximumValue>
<ValidationMessage>Propagation delay must be between 1 and 30 seconds</ValidationMessage>
</propagationDelay>
<!-- Zone Groups for DNS Management -->
<zoneGroups type="TextField">
<Required>N</Required>
<default>{}</default>
</zoneGroups>
</general>
<!-- Gateways / Interfaces -->
<gateways>
<gateway type="ArrayField">
<enabled type="BooleanField">
<default>1</default>
<Required>Y</Required>
</enabled>
<name type="TextField">
<Required>Y</Required>
<Mask>/^.{1,64}$/</Mask>
<ValidationMessage>Gateway name is required (max 64 characters)</ValidationMessage>
</name>
<interface type="InterfaceField">
<Required>Y</Required>
<AllowDynamic>Y</AllowDynamic>
<filters>
<enable>/^(?!0).*$/</enable>
</filters>
</interface>
<priority type="IntegerField">
<default>10</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>100</MaximumValue>
<Required>Y</Required>
<ValidationMessage>Priority must be between 1 and 100 (lower = higher priority)</ValidationMessage>
</priority>
<checkipMethod type="OptionField">
<Required>Y</Required>
<default>web_ipify</default>
<OptionValues>
<if>Interface IP</if>
<web_ipify>ipify.org</web_ipify>
<web_dyndns>DynDNS</web_dyndns>
<web_freedns>FreeDNS</web_freedns>
<web_ip4only>ip4only.me</web_ip4only>
<web_ip6only>ip6only.me</web_ip6only>
</OptionValues>
</checkipMethod>
<healthCheckTarget type="TextField">
<default>8.8.8.8</default>
<ValidationMessage>IP or hostname for health check</ValidationMessage>
</healthCheckTarget>
<maintenance type="BooleanField">
<default>0</default>
</maintenance>
<maintenanceScheduled type="BooleanField">
<default>0</default>
</maintenanceScheduled>
<maintenanceStart type="TextField">
<Required>N</Required>
<Mask>/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})?$/</Mask>
</maintenanceStart>
<maintenanceEnd type="TextField">
<Required>N</Required>
<Mask>/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})?$/</Mask>
</maintenanceEnd>
</gateway>
</gateways>
<!-- DNS Entries (managed records) -->
<entries>
<entry type="ArrayField">
<enabled type="BooleanField">
<default>1</default>
<Required>Y</Required>
</enabled>
<account type="ModelRelationField">
<Model>
<accounts>
<source>OPNsense.HCloudDNS.HCloudDNS</source>
<items>accounts.account</items>
<display>name</display>
</accounts>
</Model>
<Required>Y</Required>
<ValidationMessage>Account/Token is required</ValidationMessage>
</account>
<zoneId type="TextField">
<Required>Y</Required>
<ValidationMessage>Zone ID is required</ValidationMessage>
</zoneId>
<zoneName type="TextField">
<Required>Y</Required>
<ValidationMessage>Zone name is required</ValidationMessage>
</zoneName>
<recordId type="TextField">
<Required>N</Required>
</recordId>
<recordName type="TextField">
<Required>Y</Required>
<ValidationMessage>Record name is required (e.g. @ or www)</ValidationMessage>
</recordName>
<recordType type="OptionField">
<Required>Y</Required>
<default>A</default>
<OptionValues>
<A>A (IPv4)</A>
<AAAA>AAAA (IPv6)</AAAA>
</OptionValues>
</recordType>
<primaryGateway type="ModelRelationField">
<Model>
<gateways>
<source>OPNsense.HCloudDNS.HCloudDNS</source>
<items>gateways.gateway</items>
<display>name</display>
</gateways>
</Model>
<Required>N</Required>
<BlankDesc>Default Gateway (auto-detect)</BlankDesc>
</primaryGateway>
<failoverGateway type="ModelRelationField">
<Model>
<gateways>
<source>OPNsense.HCloudDNS.HCloudDNS</source>
<items>gateways.gateway</items>
<display>name</display>
</gateways>
</Model>
<Required>N</Required>
<BlankDesc>None (no failover)</BlankDesc>
</failoverGateway>
<ttl type="OptionField">
<Required>Y</Required>
<default>300</default>
<OptionValues>
<_60>60s (1 min - DynDNS)</_60>
<_120>120s (2 min)</_120>
<_300>300s (5 min - default)</_300>
<_600>600s (10 min)</_600>
<_1800>1800s (30 min)</_1800>
<_3600>3600s (1 hour)</_3600>
<_86400>86400s (1 day)</_86400>
</OptionValues>
</ttl>
<currentIp type="TextField">
<Required>N</Required>
</currentIp>
<lastUpdate type="IntegerField">
<Required>N</Required>
</lastUpdate>
<status type="OptionField">
<default>pending</default>
<OptionValues>
<pending>Pending</pending>
<active>Active</active>
<failover>Failover</failover>
<paused>Paused</paused>
<error>Error</error>
<orphaned>Orphaned</orphaned>
</OptionValues>
</status>
<!-- Dual-Stack: Link to corresponding A/AAAA record -->
<linkedEntry type="TextField">
<Required>N</Required>
</linkedEntry>
</entry>
</entries>
<!-- Notification Settings -->
<notifications>
<enabled type="BooleanField">
<default>0</default>
</enabled>
<notifyOnUpdate type="BooleanField">
<default>1</default>
</notifyOnUpdate>
<notifyOnFailover type="BooleanField">
<default>1</default>
</notifyOnFailover>
<notifyOnFailback type="BooleanField">
<default>1</default>
</notifyOnFailback>
<notifyOnError type="BooleanField">
<default>1</default>
</notifyOnError>
<notifyOnMaintenance type="BooleanField">
<default>0</default>
</notifyOnMaintenance>
<!-- Email Notifications -->
<emailEnabled type="BooleanField">
<default>0</default>
</emailEnabled>
<emailTo type="EmailField">
<Required>N</Required>
<ValidationMessage>Valid email address required</ValidationMessage>
</emailTo>
<emailFrom type="EmailField">
<Required>N</Required>
<ValidationMessage>Valid sender email address required</ValidationMessage>
</emailFrom>
<smtpServer type="TextField">
<Required>N</Required>
<Mask>/^.{0,255}$/</Mask>
</smtpServer>
<smtpPort type="IntegerField">
<default>587</default>
<MinimumValue>1</MinimumValue>
<MaximumValue>65535</MaximumValue>
</smtpPort>
<smtpTls type="OptionField">
<default>starttls</default>
<OptionValues>
<none>None</none>
<starttls>STARTTLS (Port 587)</starttls>
<ssl>SSL/TLS (Port 465)</ssl>
</OptionValues>
</smtpTls>
<smtpUser type="TextField">
<Required>N</Required>
<Mask>/^.{0,128}$/</Mask>
</smtpUser>
<smtpPassword type="TextField">
<Required>N</Required>
<Mask>/^.{0,256}$/</Mask>
</smtpPassword>
<!-- Webhook Notifications -->
<webhookEnabled type="BooleanField">
<default>0</default>
</webhookEnabled>
<webhookUrl type="UrlField">
<Required>N</Required>
<ValidationMessage>Valid URL required</ValidationMessage>
</webhookUrl>
<webhookMethod type="OptionField">
<default>POST</default>
<OptionValues>
<POST>POST</POST>
<GET>GET</GET>
</OptionValues>
</webhookMethod>
<webhookSecret type="TextField">
<Required>N</Required>
<Mask>/^.{0,256}$/</Mask>
</webhookSecret>
<!-- Ntfy Notifications -->
<ntfyEnabled type="BooleanField">
<default>0</default>
</ntfyEnabled>
<ntfyServer type="UrlField">
<default>https://ntfy.sh</default>
<Required>N</Required>
</ntfyServer>
<ntfyTopic type="TextField">
<Required>N</Required>
<Mask>/^[a-zA-Z0-9_-]{1,64}$/</Mask>
<ValidationMessage>Topic must be alphanumeric (max 64 characters)</ValidationMessage>
</ntfyTopic>
<ntfyPriority type="OptionField">
<default>default</default>
<OptionValues>
<min>Min (1)</min>
<low>Low (2)</low>
<default>Default (3)</default>
<high>High (4)</high>
<urgent>Urgent (5)</urgent>
</OptionValues>
</ntfyPriority>
</notifications>
<!-- API Accounts (Tokens) - v2: Multiple tokens supported -->
<accounts>
<account type="ArrayField">
<enabled type="BooleanField">
<default>1</default>
<Required>Y</Required>
</enabled>
<name type="TextField">
<Required>Y</Required>
<Mask>/^.{1,64}$/</Mask>
<ValidationMessage>Name is required (max 64 characters)</ValidationMessage>
</name>
<description type="TextField">
<Required>N</Required>
<Mask>/^.{0,255}$/</Mask>
</description>
<apiType type="OptionField">
<Required>Y</Required>
<default>cloud</default>
<OptionValues>
<cloud>Hetzner Cloud API</cloud>
<dns>Hetzner DNS API (deprecated)</dns>
</OptionValues>
</apiType>
<apiToken type="TextField">
<Required>Y</Required>
<Mask>/^.{10,}$/</Mask>
<ValidationMessage>API token is required (minimum 10 characters)</ValidationMessage>
</apiToken>
</account>
</accounts>
</items>
</model>

View file

@ -0,0 +1,11 @@
<menu>
<Services>
<HCloudDNS VisibleName="Hetzner Cloud DNS" cssClass="fa fa-cloud fa-fw">
<DynDNS VisibleName="DynDNS" order="10" url="/ui/hclouddns"/>
<DNS VisibleName="DNS Management" order="20" url="/ui/hclouddns/dns"/>
<History VisibleName="History" order="30" url="/ui/hclouddns/history"/>
<Settings VisibleName="Settings" order="80" url="/ui/hclouddns/settings"/>
<LogFile VisibleName="Log File" order="90" url="/ui/diagnostics/log/core/hclouddns"/>
</HCloudDNS>
</Services>
</menu>

View file

@ -0,0 +1,43 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Migrations;
use OPNsense\Base\BaseModelMigration;
use OPNsense\Core\Config;
class M2_0_1 extends BaseModelMigration
{
/**
* Migrate to 2.0.1 - Add notifications section with defaults
* @param $model
*/
public function run($model)
{
$config = Config::getInstance()->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');
}
}
}

View file

@ -0,0 +1,44 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Migrations;
use OPNsense\Base\BaseModelMigration;
use OPNsense\Core\Config;
class M2_0_2 extends BaseModelMigration
{
/**
* Migrate to 2.0.2 - Convert TTL values to OptionField format
* Old format: "300" (plain integer)
* New format: "_300" (underscore-prefixed for XML element name)
* @param $model
*/
public function run($model)
{
$config = Config::getInstance()->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;
}
}
}
}
}

View file

@ -0,0 +1,39 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Migrations;
use OPNsense\Base\BaseModelMigration;
use OPNsense\Core\Config;
class M2_0_3 extends BaseModelMigration
{
/**
* Migrate to 2.0.3 - Add CARP awareness settings
* @param $model
*/
public function run($model)
{
$config = Config::getInstance()->object();
if (!isset($config->OPNsense->HCloudDNS->general)) {
return;
}
$general = $config->OPNsense->HCloudDNS->general;
// Add carpAware field with default disabled
if (!isset($general->carpAware)) {
$general->addChild('carpAware', '0');
}
// Add carpVhid field (empty = monitor all CARP interfaces)
if (!isset($general->carpVhid)) {
$general->addChild('carpVhid', '');
}
}
}

View file

@ -0,0 +1,110 @@
<?php
/**
* Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
* All rights reserved.
*/
namespace OPNsense\HCloudDNS\Migrations;
use OPNsense\Base\BaseModelMigration;
use OPNsense\Core\Config;
class M2_1_0 extends BaseModelMigration
{
/**
* Migrate to 2.1.0:
* - Export existing config.xml history entries to JSONL file
* - Remove history section from config.xml
* - Add webhookSecret default to notifications
* - Remove deprecated apiLayer field from existing accounts
* @param $model
*/
public function run($model)
{
$config = Config::getInstance()->object();
if (!isset($config->OPNsense->HCloudDNS)) {
return;
}
$hcloud = $config->OPNsense->HCloudDNS;
// 1. Export existing history entries to JSONL before removing them
if (isset($hcloud->history) && isset($hcloud->history->change)) {
$historyDir = '/var/log/hclouddns';
$historyFile = $historyDir . '/history.jsonl';
if (!is_dir($historyDir)) {
@mkdir($historyDir, 0700, true);
}
$entries = [];
foreach ($hcloud->history->children() as $change) {
if ($change->getName() !== 'change') {
continue;
}
$uuid = (string)$change->attributes()['uuid'] ?? '';
if (empty($uuid)) {
$uuid = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
$entry = [
'uuid' => $uuid,
'timestamp' => (int)(string)$change->timestamp,
'action' => (string)$change->action,
'accountUuid' => (string)$change->accountUuid,
'accountName' => (string)$change->accountName,
'zoneId' => (string)$change->zoneId,
'zoneName' => (string)$change->zoneName,
'recordName' => (string)$change->recordName,
'recordType' => (string)$change->recordType,
'oldValue' => (string)$change->oldValue,
'oldTtl' => (int)(string)$change->oldTtl,
'newValue' => (string)$change->newValue,
'newTtl' => (int)(string)$change->newTtl,
'reverted' => ((string)$change->reverted === '1')
];
$entries[] = $entry;
}
if (!empty($entries)) {
$jsonlContent = '';
foreach ($entries as $entry) {
$jsonlContent .= json_encode($entry) . "\n";
}
file_put_contents($historyFile, $jsonlContent);
chmod($historyFile, 0600);
}
// Remove history section from config
unset($hcloud->history);
}
// 2. Add webhookSecret default to notifications
if (isset($hcloud->notifications)) {
if (!isset($hcloud->notifications->webhookSecret)) {
$hcloud->notifications->addChild('webhookSecret', '');
}
}
// 3. Remove deprecated apiLayer field from existing accounts
if (isset($hcloud->accounts)) {
foreach ($hcloud->accounts->children() as $account) {
if ($account->getName() !== 'account') {
continue;
}
if (isset($account->apiLayer)) {
unset($account->apiLayer);
}
}
}
}
}

View file

@ -0,0 +1,227 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
#}
<script>
$(document).ready(function() {
// Variables to store fetched data
var currentToken = '';
var zonesData = [];
var recordsData = [];
// Initialize bootgrid for accounts table
$("#grid-accounts").UIBootgrid({
search: '/api/hclouddns/accounts/searchItem',
get: '/api/hclouddns/accounts/getItem/',
set: '/api/hclouddns/accounts/setItem/',
add: '/api/hclouddns/accounts/addItem/',
del: '/api/hclouddns/accounts/delItem/',
toggle: '/api/hclouddns/accounts/toggleItem/',
options: {
formatters: {
commands: function(column, row) {
return '<button type="button" class="btn btn-xs btn-default command-edit" data-row-id="' + row.uuid + '"><span class="fa fa-pencil"></span></button> ' +
'<button type="button" class="btn btn-xs btn-default command-copy" data-row-id="' + row.uuid + '"><span class="fa fa-clone"></span></button> ' +
'<button type="button" class="btn btn-xs btn-default command-delete" data-row-id="' + row.uuid + '"><span class="fa fa-trash-o"></span></button>';
},
rowtoggle: function(column, row) {
if (parseInt(row[column.id], 2) === 1) {
return '<span style="cursor: pointer;" class="fa fa-check-square-o command-toggle" data-value="1" data-row-id="' + row.uuid + '"></span>';
} else {
return '<span style="cursor: pointer;" class="fa fa-square-o command-toggle" data-value="0" data-row-id="' + row.uuid + '"></span>';
}
}
}
}
});
// Load zones button handler
$(document).on('click', '#loadZonesBtn', function() {
var token = $('#account\\.apiToken').val();
if (!token) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please enter an API token first.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
currentToken = token;
$('#loadZonesBtn').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Loading...") }}');
ajaxCall('/api/hclouddns/hetzner/listZones', {token: token}, function(data, status) {
$('#loadZonesBtn').prop('disabled', false).html('<i class="fa fa-cloud-download"></i> {{ lang._("Load Zones") }}');
if (data && data.status === 'ok' && data.zones) {
zonesData = data.zones;
var $zoneSelect = $('#account\\.zoneId');
$zoneSelect.empty();
$zoneSelect.append('<option value="">{{ lang._("-- Select Zone --") }}</option>');
$.each(data.zones, function(i, zone) {
$zoneSelect.append('<option value="' + zone.id + '" data-name="' + zone.name + '">' + zone.name + ' (' + zone.records_count + ' records)</option>');
});
$zoneSelect.selectpicker('refresh');
BootstrapDialog.alert({
title: "{{ lang._('Success') }}",
message: "{{ lang._('Found') }} " + data.zones.length + " {{ lang._('zone(s).') }}",
type: BootstrapDialog.TYPE_SUCCESS
});
} else {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: data && data.message ? data.message : "{{ lang._('Failed to load zones. Check your API token.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
});
// Zone selection change - auto-fill zone name and load records
$(document).on('change', '#account\\.zoneId', function() {
var selectedOption = $(this).find('option:selected');
var zoneName = selectedOption.data('name') || '';
$('#account\\.zoneName').val(zoneName);
// Auto-load records when zone is selected
if ($(this).val() && currentToken) {
loadRecords(currentToken, $(this).val());
}
});
// Load records button handler
$(document).on('click', '#loadRecordsBtn', function() {
var token = currentToken || $('#account\\.apiToken').val();
var zoneId = $('#account\\.zoneId').val();
if (!token || !zoneId) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please select a zone first.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
loadRecords(token, zoneId);
});
function loadRecords(token, zoneId) {
$('#loadRecordsBtn').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Loading...") }}');
ajaxCall('/api/hclouddns/hetzner/listRecords', {token: token, zone_id: zoneId}, function(data, status) {
$('#loadRecordsBtn').prop('disabled', false).html('<i class="fa fa-list"></i> {{ lang._("Load Records") }}');
if (data && data.status === 'ok' && data.records) {
recordsData = data.records;
var $recordsSelect = $('#account\\.records');
$recordsSelect.empty();
$.each(data.records, function(i, record) {
var label = record.name + ' (' + record.type + ') - ' + record.value;
var value = record.name + ':' + record.type;
$recordsSelect.append('<option value="' + value + '">' + label + '</option>');
});
$recordsSelect.selectpicker('refresh');
} else {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: data && data.message ? data.message : "{{ lang._('Failed to load records.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
}
// Validate token button
$(document).on('click', '#validateTokenBtn', function() {
var token = $('#account\\.apiToken').val();
if (!token) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please enter an API token.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
$('#validateTokenBtn').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
ajaxCall('/api/hclouddns/hetzner/validateToken', {token: token}, function(data, status) {
$('#validateTokenBtn').prop('disabled', false).html('<i class="fa fa-check"></i>');
if (data && data.valid) {
currentToken = token;
BootstrapDialog.alert({
title: "{{ lang._('Valid Token') }}",
message: data.message || "{{ lang._('Token is valid.') }}",
type: BootstrapDialog.TYPE_SUCCESS
});
} else {
BootstrapDialog.alert({
title: "{{ lang._('Invalid Token') }}",
message: data && data.message ? data.message : "{{ lang._('Token validation failed.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
});
// Hook into dialog open to add custom buttons
$(document).on('opnsense_bootgrid_mapped', function(e, data) {
// Add buttons after API token field
var tokenField = $('#account\\.apiToken').closest('tr');
if (tokenField.length && !$('#validateTokenBtn').length) {
var btnHtml = '<td colspan="2" style="padding: 5px 0 15px 0;">' +
'<button type="button" class="btn btn-default btn-xs" id="validateTokenBtn"><i class="fa fa-check"></i> {{ lang._("Validate") }}</button> ' +
'<button type="button" class="btn btn-primary btn-xs" id="loadZonesBtn"><i class="fa fa-cloud-download"></i> {{ lang._("Load Zones") }}</button>' +
'</td>';
tokenField.after('<tr>' + btnHtml + '</tr>');
}
// Add load records button after zone selection
var zoneField = $('#account\\.zoneId').closest('tr');
if (zoneField.length && !$('#loadRecordsBtn').length) {
var recordsBtnHtml = '<td colspan="2" style="padding: 5px 0 15px 0;">' +
'<button type="button" class="btn btn-default btn-xs" id="loadRecordsBtn"><i class="fa fa-list"></i> {{ lang._("Load Records") }}</button>' +
'</td>';
var zoneNameField = $('#account\\.zoneName').closest('tr');
zoneNameField.after('<tr>' + recordsBtnHtml + '</tr>');
}
});
});
</script>
<div class="tab-content content-box">
<div id="accounts" class="tab-pane fade in active">
<table id="grid-accounts" class="table table-condensed table-hover table-striped" data-editDialog="DialogAccount" data-editAlert="AccountChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="enabled" data-width="6em" data-type="boolean" data-formatter="rowtoggle">{{ lang._('Enabled') }}</th>
<th data-column-id="description" data-type="string">{{ lang._('Description') }}</th>
<th data-column-id="zoneName" data-type="string">{{ lang._('Zone') }}</th>
<th data-column-id="records" data-type="string">{{ lang._('Records') }}</th>
<th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-primary"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'DialogAccount', 'label': lang._('Edit Account')]) }}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,358 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
#}
<style>
.status-active { color: #5cb85c; }
.status-failover { color: #f0ad4e; }
.status-paused { color: #999; }
.status-error { color: #d9534f; }
.status-pending { color: #5bc0de; }
.batch-actions {
background-color: #f5f5f5;
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.batch-actions .btn {
margin-right: 5px;
}
</style>
<script>
$(document).ready(function() {
var selectedUuids = [];
var gatewaysCache = {};
// Load gateways for dropdown
function loadGatewaysCache() {
ajaxCall('/api/hclouddns/gateways/searchItem', {}, function(data, status) {
if (data && data.rows) {
$.each(data.rows, function(i, gw) {
gatewaysCache[gw.uuid] = gw.name;
});
// Reload grid to update gateway names
$("#grid-entries").bootgrid('reload');
}
});
}
loadGatewaysCache();
// Initialize bootgrid for entries table
$("#grid-entries").UIBootgrid({
search: '/api/hclouddns/entries/searchItem',
get: '/api/hclouddns/entries/getItem/',
set: '/api/hclouddns/entries/setItem/',
add: '/api/hclouddns/entries/addItem/',
del: '/api/hclouddns/entries/delItem/',
toggle: '/api/hclouddns/entries/toggleItem/',
options: {
selection: true,
multiSelect: true,
rowSelect: true,
keepSelection: true,
formatters: {
commands: function(column, row) {
return '<button type="button" class="btn btn-xs btn-default command-edit" data-row-id="' + row.uuid + '"><span class="fa fa-pencil"></span></button> ' +
'<button type="button" class="btn btn-xs btn-default command-delete" data-row-id="' + row.uuid + '"><span class="fa fa-trash-o"></span></button>' +
'<button type="button" class="btn btn-xs btn-warning command-pause" data-row-id="' + row.uuid + '" title="{{ lang._("Pause/Resume") }}"><span class="fa fa-pause"></span></button>' +
'<button type="button" class="btn btn-xs btn-info command-refresh" data-row-id="' + row.uuid + '" title="{{ lang._("Refresh from Hetzner") }}"><span class="fa fa-refresh"></span></button>';
},
rowtoggle: function(column, row) {
if (parseInt(row[column.id], 2) === 1) {
return '<span style="cursor: pointer;" class="fa fa-check-square-o command-toggle" data-value="1" data-row-id="' + row.uuid + '"></span>';
} else {
return '<span style="cursor: pointer;" class="fa fa-square-o command-toggle" data-value="0" data-row-id="' + row.uuid + '"></span>';
}
},
status: function(column, row) {
var status = row[column.id] || 'pending';
var icons = {
'active': '<span class="status-active"><i class="fa fa-circle"></i> {{ lang._("Active") }}</span>',
'failover': '<span class="status-failover"><i class="fa fa-exclamation-circle"></i> {{ lang._("Failover") }}</span>',
'paused': '<span class="status-paused"><i class="fa fa-pause-circle"></i> {{ lang._("Paused") }}</span>',
'error': '<span class="status-error"><i class="fa fa-times-circle"></i> {{ lang._("Error") }}</span>',
'pending': '<span class="status-pending"><i class="fa fa-clock-o"></i> {{ lang._("Pending") }}</span>'
};
return icons[status] || icons['pending'];
},
gateway: function(column, row) {
var uuid = row[column.id];
return gatewaysCache[uuid] || uuid.substr(0, 8) + '...';
},
record: function(column, row) {
var name = row.recordName || '';
var zone = row.zoneName || '';
if (name === '@') {
return '<code>' + zone + '</code>';
}
return '<code>' + name + '.' + zone + '</code>';
}
}
}
});
// Track selection changes
$("#grid-entries").on("selected.rs.jquery.bootgrid", function(e, rows) {
updateSelection();
}).on("deselected.rs.jquery.bootgrid", function(e, rows) {
updateSelection();
});
function updateSelection() {
selectedUuids = $("#grid-entries").bootgrid("getSelectedRows");
if (selectedUuids.length > 0) {
$('#batchActions').show();
$('#selectedCount').text(selectedUuids.length);
} else {
$('#batchActions').hide();
}
}
// Pause/Resume single entry
$(document).on('click', '.command-pause', function() {
var uuid = $(this).data('row-id');
ajaxCall('/api/hclouddns/entries/pause/' + uuid, {}, function(data, status) {
if (data && data.status === 'ok') {
$("#grid-entries").bootgrid('reload');
}
});
});
// Refresh single entry from Hetzner
$(document).on('click', '.command-refresh', function() {
var uuid = $(this).data('row-id');
var $btn = $(this);
$btn.find('span').addClass('fa-spin');
ajaxCall('/api/hclouddns/entries/getHetznerIp/' + uuid, {}, function(data, status) {
$btn.find('span').removeClass('fa-spin');
if (data && data.status === 'ok') {
BootstrapDialog.show({
title: "{{ lang._('Hetzner DNS Status') }}",
message: '<strong>{{ lang._("Current IP at Hetzner") }}:</strong> ' + (data.ip || '{{ lang._("Not found") }}') + '<br>' +
'<strong>{{ lang._("Record ID") }}:</strong> ' + (data.recordId || '{{ lang._("N/A") }}') + '<br>' +
'<strong>{{ lang._("TTL") }}:</strong> ' + (data.ttl || '{{ lang._("N/A") }}') + 's',
type: BootstrapDialog.TYPE_INFO,
buttons: [{
label: "{{ lang._('Close') }}",
action: function(dialog) { dialog.close(); }
}]
});
} else {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: data && data.message ? data.message : "{{ lang._('Failed to get Hetzner IP.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
});
// Batch pause
$('#batchPauseBtn').click(function() {
if (selectedUuids.length === 0) return;
ajaxCall('/api/hclouddns/entries/batchUpdate', {
uuids: selectedUuids,
action: 'pause'
}, function(data, status) {
if (data && data.status === 'ok') {
$("#grid-entries").bootgrid('reload');
BootstrapDialog.alert({
title: "{{ lang._('Success') }}",
message: data.processed + ' {{ lang._("entries paused.") }}',
type: BootstrapDialog.TYPE_SUCCESS
});
}
});
});
// Batch resume
$('#batchResumeBtn').click(function() {
if (selectedUuids.length === 0) return;
ajaxCall('/api/hclouddns/entries/batchUpdate', {
uuids: selectedUuids,
action: 'resume'
}, function(data, status) {
if (data && data.status === 'ok') {
$("#grid-entries").bootgrid('reload');
BootstrapDialog.alert({
title: "{{ lang._('Success') }}",
message: data.processed + ' {{ lang._("entries resumed.") }}',
type: BootstrapDialog.TYPE_SUCCESS
});
}
});
});
// Batch delete
$('#batchDeleteBtn').click(function() {
if (selectedUuids.length === 0) return;
BootstrapDialog.confirm({
title: "{{ lang._('Confirm Delete') }}",
message: "{{ lang._('Are you sure you want to delete') }} " + selectedUuids.length + " {{ lang._('entries?') }}",
type: BootstrapDialog.TYPE_DANGER,
btnOKLabel: "{{ lang._('Delete') }}",
btnOKClass: 'btn-danger',
callback: function(result) {
if (result) {
ajaxCall('/api/hclouddns/entries/batchUpdate', {
uuids: selectedUuids,
action: 'delete'
}, function(data, status) {
if (data && data.status === 'ok') {
$("#grid-entries").bootgrid('reload');
selectedUuids = [];
updateSelection();
}
});
}
}
});
});
// Batch change gateway
$('#batchGatewayBtn').click(function() {
if (selectedUuids.length === 0) return;
// Build gateway select options
var options = '<option value="">{{ lang._("-- Select Gateway --") }}</option>';
$.each(gatewaysCache, function(uuid, name) {
options += '<option value="' + uuid + '">' + name + '</option>';
});
BootstrapDialog.show({
title: "{{ lang._('Change Primary Gateway') }}",
message: '<div class="form-group">' +
'<label>{{ lang._("New Primary Gateway") }}</label>' +
'<select id="newGatewaySelect" class="form-control">' + options + '</select>' +
'</div>',
buttons: [{
label: "{{ lang._('Cancel') }}",
action: function(dialog) { dialog.close(); }
}, {
label: "{{ lang._('Apply') }}",
cssClass: 'btn-primary',
action: function(dialog) {
var newGateway = $('#newGatewaySelect').val();
if (!newGateway) {
alert("{{ lang._('Please select a gateway.') }}");
return;
}
ajaxCall('/api/hclouddns/entries/batchUpdate', {
uuids: selectedUuids,
action: 'setGateway',
gateway: newGateway
}, function(data, status) {
if (data && data.status === 'ok') {
$("#grid-entries").bootgrid('reload');
dialog.close();
}
});
}
}]
});
});
// Refresh all entries status
$('#refreshAllBtn').click(function() {
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Refreshing...") }}');
ajaxCall('/api/hclouddns/entries/refreshStatus', {}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-refresh"></i> {{ lang._("Refresh Status") }}');
$("#grid-entries").bootgrid('reload');
if (data && data.status === 'ok') {
BootstrapDialog.alert({
title: "{{ lang._('Status Refreshed') }}",
message: data.entries.length + ' {{ lang._("entries checked.") }}',
type: BootstrapDialog.TYPE_SUCCESS
});
}
});
});
// Update all records now
$('#updateNowBtn').click(function() {
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Updating...") }}');
ajaxCall('/api/hclouddns/service/updateV2', {}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-bolt"></i> {{ lang._("Update Now") }}');
$("#grid-entries").bootgrid('reload');
if (data) {
var message = data.message || '{{ lang._("Update completed.") }}';
var type = data.status === 'ok' ? BootstrapDialog.TYPE_SUCCESS :
(data.status === 'warning' ? BootstrapDialog.TYPE_WARNING : BootstrapDialog.TYPE_INFO);
BootstrapDialog.alert({
title: "{{ lang._('Update Result') }}",
message: message,
type: type
});
}
});
});
});
</script>
<div class="tab-content content-box">
<div id="entries" class="tab-pane fade in active">
<div class="content-box-main">
<div class="col-md-12">
<h2>{{ lang._('DNS Entries') }}</h2>
<p class="text-muted">{{ lang._('Manage your dynamic DNS entries. Select multiple entries for batch operations.') }}</p>
</div>
<!-- Batch Actions -->
<div class="col-md-12">
<div class="batch-actions" id="batchActions" style="display: none;">
<strong><span id="selectedCount">0</span> {{ lang._('selected') }}:</strong>
<button type="button" class="btn btn-sm btn-warning" id="batchPauseBtn"><i class="fa fa-pause"></i> {{ lang._('Pause') }}</button>
<button type="button" class="btn btn-sm btn-success" id="batchResumeBtn"><i class="fa fa-play"></i> {{ lang._('Resume') }}</button>
<button type="button" class="btn btn-sm btn-info" id="batchGatewayBtn"><i class="fa fa-exchange"></i> {{ lang._('Change Gateway') }}</button>
<button type="button" class="btn btn-sm btn-danger" id="batchDeleteBtn"><i class="fa fa-trash"></i> {{ lang._('Delete') }}</button>
</div>
</div>
</div>
<table id="grid-entries" class="table table-condensed table-hover table-striped" data-editDialog="DialogEntry" data-editAlert="EntryChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="enabled" data-width="5em" data-type="boolean" data-formatter="rowtoggle">{{ lang._('On') }}</th>
<th data-column-id="zoneName" data-type="string" data-formatter="record">{{ lang._('Record') }}</th>
<th data-column-id="recordType" data-width="5em" data-type="string">{{ lang._('Type') }}</th>
<th data-column-id="currentIp" data-type="string">{{ lang._('Current IP') }}</th>
<th data-column-id="primaryGateway" data-type="string" data-formatter="gateway">{{ lang._('Gateway') }}</th>
<th data-column-id="status" data-width="8em" data-type="string" data-formatter="status">{{ lang._('Status') }}</th>
<th data-column-id="commands" data-width="10em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-primary"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
<button type="button" class="btn btn-xs btn-info" id="refreshAllBtn"><i class="fa fa-refresh"></i> {{ lang._('Refresh Status') }}</button>
<button type="button" class="btn btn-xs btn-success" id="updateNowBtn"><i class="fa fa-bolt"></i> {{ lang._('Update Now') }}</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{ partial("layout_partials/base_dialog", ['fields': entryForm, 'id': 'DialogEntry', 'label': lang._('Edit Entry')]) }}

View file

@ -0,0 +1,155 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
#}
<script>
$(document).ready(function() {
// Initialize bootgrid for gateways table
$("#grid-gateways").UIBootgrid({
search: '/api/hclouddns/gateways/searchItem',
get: '/api/hclouddns/gateways/getItem/',
set: '/api/hclouddns/gateways/setItem/',
add: '/api/hclouddns/gateways/addItem/',
del: '/api/hclouddns/gateways/delItem/',
toggle: '/api/hclouddns/gateways/toggleItem/',
options: {
formatters: {
commands: function(column, row) {
return '<button type="button" class="btn btn-xs btn-default command-edit" data-row-id="' + row.uuid + '"><span class="fa fa-pencil"></span></button> ' +
'<button type="button" class="btn btn-xs btn-default command-copy" data-row-id="' + row.uuid + '"><span class="fa fa-clone"></span></button> ' +
'<button type="button" class="btn btn-xs btn-default command-delete" data-row-id="' + row.uuid + '"><span class="fa fa-trash-o"></span></button>' +
'<button type="button" class="btn btn-xs btn-info command-health" data-row-id="' + row.uuid + '" title="{{ lang._("Check Health") }}"><span class="fa fa-heartbeat"></span></button>';
},
rowtoggle: function(column, row) {
if (parseInt(row[column.id], 2) === 1) {
return '<span style="cursor: pointer;" class="fa fa-check-square-o command-toggle" data-value="1" data-row-id="' + row.uuid + '"></span>';
} else {
return '<span style="cursor: pointer;" class="fa fa-square-o command-toggle" data-value="0" data-row-id="' + row.uuid + '"></span>';
}
},
status: function(column, row) {
var statusHtml = '<span class="label label-default">{{ lang._("Unknown") }}</span>';
return statusHtml;
}
}
}
});
// Health check button handler
$(document).on('click', '.command-health', function() {
var uuid = $(this).data('row-id');
var $btn = $(this);
$btn.prop('disabled', true);
$btn.find('span').removeClass('fa-heartbeat').addClass('fa-spinner fa-spin');
ajaxCall('/api/hclouddns/gateways/checkHealth/' + uuid, {}, function(data, status) {
$btn.prop('disabled', false);
$btn.find('span').removeClass('fa-spinner fa-spin').addClass('fa-heartbeat');
if (data) {
var statusClass = data.status === 'up' ? 'success' : (data.status === 'down' ? 'danger' : 'warning');
var message = '<strong>{{ lang._("Status") }}:</strong> ' + data.status.toUpperCase() + '<br>' +
'<strong>{{ lang._("IPv4") }}:</strong> ' + (data.ipv4 || '{{ lang._("N/A") }}') + '<br>' +
'<strong>{{ lang._("IPv6") }}:</strong> ' + (data.ipv6 || '{{ lang._("N/A") }}');
if (data.pingOk !== null) {
message += '<br><strong>{{ lang._("Ping") }}:</strong> ' + (data.pingOk ? '✓' : '✗');
}
if (data.httpOk !== null) {
message += '<br><strong>{{ lang._("HTTP") }}:</strong> ' + (data.httpOk ? '✓' : '✗');
}
BootstrapDialog.show({
type: statusClass === 'success' ? BootstrapDialog.TYPE_SUCCESS :
(statusClass === 'danger' ? BootstrapDialog.TYPE_DANGER : BootstrapDialog.TYPE_WARNING),
title: "{{ lang._('Gateway Health Check') }}",
message: message,
buttons: [{
label: "{{ lang._('Close') }}",
action: function(dialog) { dialog.close(); }
}]
});
}
});
});
// Refresh all gateway status
$('#refreshGatewaysBtn').click(function() {
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Checking...") }}');
ajaxCall('/api/hclouddns/gateways/status', {}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-refresh"></i> {{ lang._("Refresh Status") }}');
$("#grid-gateways").bootgrid('reload');
if (data && data.gateways) {
var statusHtml = '<table class="table table-condensed"><thead><tr>' +
'<th>{{ lang._("Gateway") }}</th><th>{{ lang._("Status") }}</th><th>{{ lang._("IPv4") }}</th><th>{{ lang._("IPv6") }}</th>' +
'</tr></thead><tbody>';
$.each(data.gateways, function(uuid, gw) {
var statusClass = gw.status === 'up' ? 'success' : (gw.status === 'down' ? 'danger' : 'default');
statusHtml += '<tr>' +
'<td>' + uuid.substr(0, 8) + '...</td>' +
'<td><span class="label label-' + statusClass + '">' + gw.status.toUpperCase() + '</span></td>' +
'<td>' + (gw.ipv4 || '-') + '</td>' +
'<td>' + (gw.ipv6 || '-') + '</td>' +
'</tr>';
});
statusHtml += '</tbody></table>';
BootstrapDialog.show({
title: "{{ lang._('Gateway Status') }}",
message: statusHtml,
buttons: [{
label: "{{ lang._('Close') }}",
action: function(dialog) { dialog.close(); }
}]
});
}
});
});
});
</script>
<div class="tab-content content-box">
<div id="gateways" class="tab-pane fade in active">
<div class="content-box-main">
<div class="table-responsive">
<div class="col-md-12">
<h2>{{ lang._('Gateways') }}</h2>
<p class="text-muted">{{ lang._('Configure WAN interfaces for dynamic DNS updates. Each gateway can have its own IP detection method and health check settings.') }}</p>
</div>
</div>
</div>
<table id="grid-gateways" class="table table-condensed table-hover table-striped" data-editDialog="DialogGateway" data-editAlert="GatewayChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="enabled" data-width="6em" data-type="boolean" data-formatter="rowtoggle">{{ lang._('Enabled') }}</th>
<th data-column-id="name" data-type="string">{{ lang._('Name') }}</th>
<th data-column-id="interface" data-type="string">{{ lang._('Interface') }}</th>
<th data-column-id="priority" data-width="6em" data-type="string">{{ lang._('Priority') }}</th>
<th data-column-id="checkipMethod" data-type="string">{{ lang._('IP Method') }}</th>
<th data-column-id="commands" data-width="10em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-primary"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
<button type="button" class="btn btn-xs btn-info" id="refreshGatewaysBtn"><i class="fa fa-refresh"></i> {{ lang._('Refresh Status') }}</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{ partial("layout_partials/base_dialog", ['fields': gatewayForm, 'id': 'DialogGateway', 'label': lang._('Edit Gateway')]) }}

View file

@ -0,0 +1,366 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
#}
<style>
.status-overview {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.status-item {
display: inline-block;
margin-right: 30px;
padding: 10px 0;
}
.status-item .label {
font-size: 14px;
padding: 6px 12px;
}
.status-item strong {
display: block;
margin-bottom: 5px;
color: #666;
font-size: 12px;
text-transform: uppercase;
}
.token-actions {
margin-top: 5px;
}
.token-status {
margin-left: 10px;
}
.config-summary {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.summary-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.summary-card {
flex: 1;
min-width: 200px;
background: #fafafa;
border: 1px solid #eee;
border-radius: 4px;
padding: 15px;
}
.summary-card h5 {
margin: 0 0 10px 0;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.summary-card .count {
font-size: 24px;
font-weight: bold;
color: #337ab7;
}
.summary-card ul {
margin: 0;
padding-left: 20px;
}
.form-section {
margin-bottom: 25px;
}
.form-section h4 {
border-bottom: 2px solid #337ab7;
padding-bottom: 8px;
margin-bottom: 15px;
}
</style>
<script>
$(document).ready(function() {
// Load form data
var data_get_map = {'frm_general_settings': '/api/hclouddns/settings/get'};
mapDataToFormUI(data_get_map).done(function(data) {
formatTokenizersUI();
$('.selectpicker').selectpicker('refresh');
loadStatusOverview();
});
// Load status overview
function loadStatusOverview() {
ajaxCall('/api/hclouddns/settings/get', {}, function(data, status) {
if (data && data.hclouddns) {
var cfg = data.hclouddns;
var general = cfg.general || {};
// Service status
if (general.enabled === '1') {
$('#svcStatus').html('<span class="label label-success">Aktiviert</span>');
} else {
$('#svcStatus').html('<span class="label label-default">Deaktiviert</span>');
}
// API Type
var apiType = 'Cloud API';
if (general.apiType) {
for (var key in general.apiType) {
if (general.apiType[key].selected === 1) {
apiType = key === 'dns' ? 'Legacy API' : 'Cloud API';
break;
}
}
}
$('#apiTypeStatus').html('<span class="label label-info">' + apiType + '</span>');
// Token status
var hasToken = general.apiToken && general.apiToken.length > 0;
if (hasToken) {
$('#tokenStatus').html('<span class="label label-success">Konfiguriert</span>');
} else {
$('#tokenStatus').html('<span class="label label-warning">Nicht gesetzt</span>');
}
// Failover status
if (general.failoverEnabled === '1') {
$('#failoverStatus').html('<span class="label label-info">Aktiviert</span>');
} else {
$('#failoverStatus').html('<span class="label label-default">Deaktiviert</span>');
}
// Count gateways
var gwCount = 0;
var gwList = [];
if (cfg.gateways && cfg.gateways.gateway) {
for (var uuid in cfg.gateways.gateway) {
gwCount++;
var gw = cfg.gateways.gateway[uuid];
gwList.push(gw.name + (gw.enabled === '1' ? '' : ' (deaktiviert)'));
}
}
$('#gatewayCount').text(gwCount);
if (gwList.length > 0) {
$('#gatewayList').html('<ul><li>' + gwList.join('</li><li>') + '</li></ul>');
} else {
$('#gatewayList').html('<em class="text-muted">Keine konfiguriert</em>');
}
// Count entries
var entryCount = 0;
var zoneSet = {};
if (cfg.entries && cfg.entries.entry) {
for (var uuid in cfg.entries.entry) {
entryCount++;
var entry = cfg.entries.entry[uuid];
zoneSet[entry.zoneName] = true;
}
}
$('#entryCount').text(entryCount);
var zones = Object.keys(zoneSet);
if (zones.length > 0) {
$('#zoneList').html('<ul><li>' + zones.join('</li><li>') + '</li></ul>');
} else {
$('#zoneList').html('<em class="text-muted">Keine konfiguriert</em>');
}
}
});
}
// Validate token button
$('#validateTokenBtn').click(function() {
var token = $('input[id="general\\.apiToken"]').val();
if (!token) {
$('#tokenValidation').html('<span class="label label-warning">Bitte Token eingeben</span>');
return;
}
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
$('#tokenValidation').html('<i class="fa fa-spinner fa-spin"></i> Prüfe...');
ajaxCall('/api/hclouddns/hetzner/listZones', {token: token}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-check-circle"></i> Prüfen');
if (data && data.status === 'ok' && data.zones) {
$('#tokenValidation').html(
'<span class="label label-success">Gültig</span> ' +
'<small>' + data.zones.length + ' Zone(n) gefunden</small>'
);
} else {
$('#tokenValidation').html(
'<span class="label label-danger">Ungültig</span> ' +
'<small>' + (data && data.message ? data.message : 'Token nicht erkannt') + '</small>'
);
}
});
});
// Toggle token visibility
$('#toggleTokenBtn').click(function() {
var $input = $('input[id="general\\.apiToken"]');
if ($input.attr('type') === 'password') {
$input.attr('type', 'text');
$(this).html('<i class="fa fa-eye-slash"></i>');
} else {
$input.attr('type', 'password');
$(this).html('<i class="fa fa-eye"></i>');
}
});
// Save settings
$("#saveAct").click(function() {
saveFormToEndpoint('/api/hclouddns/settings/set', 'frm_general_settings', function() {
ajaxCall('/api/hclouddns/service/reconfigure', {}, function(data, status) {
loadStatusOverview();
BootstrapDialog.show({
type: BootstrapDialog.TYPE_SUCCESS,
title: "{{ lang._('Saved') }}",
message: "{{ lang._('Settings have been saved and applied.') }}",
buttons: [{
label: "{{ lang._('OK') }}",
action: function(dialog) { dialog.close(); }
}]
});
});
}, true);
});
// Update button
$("#updateAct").click(function() {
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Updating...") }}');
ajaxCall('/api/hclouddns/service/updateV2', {}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-refresh"></i> {{ lang._("Update Now") }}');
if (data && data.status === 'ok') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_SUCCESS,
title: "{{ lang._('Update Complete') }}",
message: "{{ lang._('DNS records have been updated.') }}",
buttons: [{
label: "{{ lang._('Close') }}",
action: function(dialog) { dialog.close(); }
}]
});
} else {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_WARNING,
title: "{{ lang._('Update') }}",
message: data && data.message ? data.message : "{{ lang._('Update completed with warnings.') }}",
buttons: [{
label: "{{ lang._('Close') }}",
action: function(dialog) { dialog.close(); }
}]
});
}
});
});
});
</script>
<div class="tab-content content-box">
<div id="general" class="tab-pane fade in active">
<div class="content-box-main">
<!-- Status Overview -->
<div class="col-md-12">
<h2><i class="fa fa-cloud"></i> {{ lang._('Hetzner Cloud Dynamic DNS') }}</h2>
</div>
<div class="col-md-12">
<div class="status-overview">
<div class="status-item">
<strong>{{ lang._('Service') }}</strong>
<span id="svcStatus"><i class="fa fa-spinner fa-spin"></i></span>
</div>
<div class="status-item">
<strong>{{ lang._('API') }}</strong>
<span id="apiTypeStatus"><i class="fa fa-spinner fa-spin"></i></span>
</div>
<div class="status-item">
<strong>{{ lang._('Token') }}</strong>
<span id="tokenStatus"><i class="fa fa-spinner fa-spin"></i></span>
</div>
<div class="status-item">
<strong>{{ lang._('Failover') }}</strong>
<span id="failoverStatus"><i class="fa fa-spinner fa-spin"></i></span>
</div>
</div>
</div>
<!-- Configuration Summary -->
<div class="col-md-12">
<div class="config-summary">
<h4>{{ lang._('Configuration Summary') }}</h4>
<div class="summary-grid">
<div class="summary-card">
<h5><i class="fa fa-server"></i> {{ lang._('Gateways') }}</h5>
<div class="count" id="gatewayCount">-</div>
<div id="gatewayList"><i class="fa fa-spinner fa-spin"></i></div>
<a href="/ui/hclouddns/gateways" class="btn btn-xs btn-default" style="margin-top:10px;">
<i class="fa fa-cog"></i> {{ lang._('Manage') }}
</a>
</div>
<div class="summary-card">
<h5><i class="fa fa-list"></i> {{ lang._('DNS Entries') }}</h5>
<div class="count" id="entryCount">-</div>
<div id="zoneList"><i class="fa fa-spinner fa-spin"></i></div>
<a href="/ui/hclouddns/entries" class="btn btn-xs btn-default" style="margin-top:10px;">
<i class="fa fa-cog"></i> {{ lang._('Manage') }}
</a>
</div>
</div>
</div>
</div>
<!-- Settings Form -->
<div class="col-md-12">
<div class="form-section">
<h4><i class="fa fa-cogs"></i> {{ lang._('Settings') }}</h4>
{{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }}
</div>
</div>
<!-- Token Actions (injected after form loads) -->
<div class="col-md-12" id="tokenActionsContainer" style="display:none;">
<div class="token-actions" style="margin-top:-15px; margin-bottom:20px;">
<button type="button" class="btn btn-xs btn-default" id="toggleTokenBtn" title="{{ lang._('Show/Hide Token') }}">
<i class="fa fa-eye"></i>
</button>
<button type="button" class="btn btn-xs btn-info" id="validateTokenBtn">
<i class="fa fa-check-circle"></i> {{ lang._('Validate') }}
</button>
<span id="tokenValidation" class="token-status"></span>
</div>
</div>
<!-- Action Buttons -->
<div class="col-md-12">
<hr/>
<button class="btn btn-primary" id="saveAct" type="button">
<i class="fa fa-save"></i> <b>{{ lang._('Save') }}</b>
<i id="saveAct_progress" class=""></i>
</button>
<button class="btn btn-default" id="updateAct" type="button">
<i class="fa fa-refresh"></i> <b>{{ lang._('Update Now') }}</b>
<i id="updateAct_progress" class=""></i>
</button>
<a href="/ui/hclouddns/status" class="btn btn-default">
<i class="fa fa-dashboard"></i> {{ lang._('Status Dashboard') }}
</a>
</div>
</div>
</div>
</div>
<script>
// Move token actions after the token field once form is loaded
$(document).ready(function() {
setTimeout(function() {
var $tokenRow = $('input[id="general\\.apiToken"]').closest('tr');
if ($tokenRow.length) {
$('#tokenActionsContainer').show().insertAfter($tokenRow.closest('table'));
}
}, 500);
});
</script>

View file

@ -0,0 +1,581 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Hetzner Cloud DNS - Audit Dashboard & Change History
#}
<style>
.content-box-header { padding: 15px 30px; }
.content-box-main { padding: 20px 30px; }
/* Dashboard Filters */
.dashboard-filters { display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end; }
.dashboard-filters .form-group { margin-bottom: 0; }
.dashboard-filters label { font-size: 12px; color: #666; display: block; margin-bottom: 3px; }
/* Stats Tiles */
.history-stats { display: flex; gap: 15px; margin-bottom: 25px; flex-wrap: wrap; }
.history-stat { background: #f8f9fa; padding: 15px 20px; border-radius: 8px; text-align: center; min-width: 100px; flex: 1; }
.history-stat .number { font-size: 28px; font-weight: 700; }
.history-stat .label { font-size: 11px; color: #666; margin-top: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.history-stat.creates .number { color: #28a745; }
.history-stat.updates .number { color: #17a2b8; }
.history-stat.deletes .number { color: #dc3545; }
.history-stat.reverted .number { color: #6c757d; }
.history-stat.avg .number { color: #6610f2; }
/* Activity Timeline */
.activity-timeline { margin-bottom: 25px; }
.activity-timeline h5 { margin-bottom: 10px; font-size: 14px; font-weight: 600; }
.timeline-chart { display: flex; align-items: stretch; gap: 2px; height: 120px; padding: 10px 0; border-bottom: 1px solid #ddd; }
.timeline-bar { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; min-width: 8px; max-width: 30px; position: relative; cursor: pointer; }
.timeline-bar:hover .timeline-tooltip { display: block; }
.timeline-bar-segment { width: 100%; transition: height 0.3s ease; }
.timeline-bar-segment.create { background: #28a745; }
.timeline-bar-segment.update { background: #17a2b8; }
.timeline-bar-segment.delete { background: #dc3545; }
.timeline-tooltip { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 4px 8px; border-radius: 3px; font-size: 11px; white-space: nowrap; z-index: 10; margin-bottom: 5px; }
.timeline-labels { display: flex; gap: 2px; justify-content: space-between; padding-top: 5px; }
.timeline-labels span { font-size: 10px; color: #999; flex: 1; text-align: center; }
.timeline-legend { display: flex; gap: 15px; margin-top: 8px; font-size: 11px; color: #666; }
.timeline-legend-item { display: flex; align-items: center; gap: 4px; }
.timeline-legend-dot { width: 10px; height: 10px; border-radius: 2px; }
/* Action Breakdown */
.dashboard-row { display: flex; gap: 20px; margin-bottom: 25px; flex-wrap: wrap; }
.dashboard-panel { flex: 1; min-width: 280px; background: #f8f9fa; border-radius: 8px; padding: 15px 20px; }
.dashboard-panel h5 { margin-top: 0; margin-bottom: 15px; font-size: 14px; font-weight: 600; }
.breakdown-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.breakdown-label { min-width: 70px; font-size: 12px; color: #555; }
.breakdown-track { flex: 1; background: #e9ecef; height: 18px; border-radius: 3px; overflow: hidden; }
.breakdown-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; display: flex; align-items: center; padding: 0 6px; font-size: 10px; color: #fff; font-weight: 600; }
.breakdown-fill.create { background: #28a745; }
.breakdown-fill.update { background: #17a2b8; }
.breakdown-fill.delete { background: #dc3545; }
.breakdown-value { min-width: 40px; text-align: right; font-size: 12px; font-weight: 600; }
/* Top Zones */
.top-zone-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.top-zone-name { min-width: 140px; font-size: 12px; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-zone-track { flex: 1; background: #e9ecef; height: 14px; border-radius: 3px; overflow: hidden; }
.top-zone-fill { height: 100%; background: #337ab7; border-radius: 3px; transition: width 0.5s ease; }
.top-zone-count { min-width: 30px; text-align: right; font-size: 12px; font-weight: 600; color: #555; }
/* History Table */
.record-code { font-family: monospace; background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
.value-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.value-cell:hover { white-space: normal; word-break: break-all; }
/* Empty State */
.empty-state { text-align: center; padding: 50px 20px; color: #999; }
.empty-state i.fa { font-size: 48px; margin-bottom: 15px; display: block; color: #ccc; }
.empty-state p { font-size: 14px; max-width: 500px; margin: 0 auto 10px; }
/* Button spacing */
.history-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.history-actions .danger-zone { margin-left: auto; display: flex; gap: 10px; }
.history-table-info { font-size: 12px; color: #999; margin-bottom: 10px; }
</style>
<div class="content-box">
<div class="content-box-header">
<h3><i class="fa fa-bar-chart"></i> {{ lang._('DNS Audit Dashboard') }}</h3>
</div>
<div class="content-box-main">
<p class="text-muted">
{{ lang._('Complete audit trail and analytics of all DNS changes - automatic updates (DynDNS, failover) and manual changes.') }}
</p>
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
{{ lang._('History retention is configured in Settings (current:') }} <span id="retentionDays">...</span> {{ lang._('days).') }}
</div>
<!-- Dashboard Filters -->
<div class="dashboard-filters">
<div class="form-group">
<label>{{ lang._('Time Range') }}</label>
<select class="form-control input-sm" id="dashboardRange" style="width: 140px;">
<option value="1">{{ lang._('Today') }}</option>
<option value="7" selected>{{ lang._('Last 7 Days') }}</option>
<option value="30">{{ lang._('Last 30 Days') }}</option>
<option value="90">{{ lang._('Last 90 Days') }}</option>
</select>
</div>
<div class="form-group">
<label>{{ lang._('Account') }}</label>
<select class="form-control input-sm" id="dashboardAccount" style="width: 180px;">
<option value="">{{ lang._('All Accounts') }}</option>
</select>
</div>
<div class="form-group">
<button class="btn btn-sm btn-default" id="refreshDashboardBtn"><i class="fa fa-refresh"></i> {{ lang._('Refresh') }}</button>
</div>
</div>
<!-- Statistics Tiles -->
<div class="history-stats" id="historyStats">
<div class="history-stat">
<div class="number" id="statTotal">-</div>
<div class="label">{{ lang._('Total') }}</div>
</div>
<div class="history-stat creates">
<div class="number" id="statCreates">-</div>
<div class="label">{{ lang._('Creates') }}</div>
</div>
<div class="history-stat updates">
<div class="number" id="statUpdates">-</div>
<div class="label">{{ lang._('Updates') }}</div>
</div>
<div class="history-stat deletes">
<div class="number" id="statDeletes">-</div>
<div class="label">{{ lang._('Deletes') }}</div>
</div>
<div class="history-stat reverted">
<div class="number" id="statReverted">-</div>
<div class="label">{{ lang._('Reverted') }}</div>
</div>
<div class="history-stat avg">
<div class="number" id="statAvg">-</div>
<div class="label">{{ lang._('Avg/Day') }}</div>
</div>
</div>
<!-- Activity Timeline -->
<div class="activity-timeline">
<h5><i class="fa fa-line-chart"></i> {{ lang._('Activity Timeline') }}</h5>
<div class="timeline-chart" id="timelineChart">
<div class="text-center text-muted" style="width:100%;padding:40px;">
<i class="fa fa-spinner fa-spin"></i>
</div>
</div>
<div class="timeline-labels" id="timelineLabels"></div>
<div class="timeline-legend">
<div class="timeline-legend-item"><div class="timeline-legend-dot" style="background:#28a745;"></div> {{ lang._('Create') }}</div>
<div class="timeline-legend-item"><div class="timeline-legend-dot" style="background:#17a2b8;"></div> {{ lang._('Update') }}</div>
<div class="timeline-legend-item"><div class="timeline-legend-dot" style="background:#dc3545;"></div> {{ lang._('Delete') }}</div>
</div>
</div>
<!-- Breakdown + Top Zones Row -->
<div class="dashboard-row">
<div class="dashboard-panel">
<h5><i class="fa fa-pie-chart"></i> {{ lang._('Action Breakdown') }}</h5>
<div id="actionBreakdown">
<div class="text-center text-muted"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
<div class="dashboard-panel">
<h5><i class="fa fa-trophy"></i> {{ lang._('Top Zones') }}</h5>
<div id="topZones">
<div class="text-center text-muted"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<hr/>
<h4><i class="fa fa-history"></i> {{ lang._('Change History') }}</h4>
<div class="history-table-info" id="historyTableInfo"></div>
<!-- History Table -->
<table class="table table-condensed table-hover table-striped" id="historyTable">
<thead>
<tr>
<th style="width:150px;">{{ lang._('Time') }}</th>
<th style="width:80px;">{{ lang._('Action') }}</th>
<th>{{ lang._('Record') }}</th>
<th style="width:60px;">{{ lang._('Type') }}</th>
<th>{{ lang._('Old Value') }}</th>
<th>{{ lang._('New Value') }}</th>
<th>{{ lang._('Account') }}</th>
<th style="width:80px;">{{ lang._('Status') }}</th>
<th style="width:80px;"></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted"><i class="fa fa-spinner fa-spin"></i> {{ lang._('Loading...') }}</td></tr>
</tbody>
</table>
<hr/>
<div class="history-actions">
<button class="btn btn-default" id="refreshHistoryBtn"><i class="fa fa-refresh"></i> {{ lang._('Refresh') }}</button>
<div class="danger-zone">
<button class="btn btn-warning" id="cleanupHistoryBtn"><i class="fa fa-trash"></i> {{ lang._('Cleanup Old Entries') }}</button>
<button class="btn btn-danger" id="clearAllHistoryBtn"><i class="fa fa-times"></i> {{ lang._('Clear All') }}</button>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function() {
updateServiceControlUI('hclouddns');
var allHistoryRows = []; // cache for client-side filtering
// Load retention days from settings
ajaxCall('/api/hclouddns/settings/get', {}, function(data) {
if (data && data.hclouddns && data.hclouddns.general) {
$('#retentionDays').text(data.hclouddns.general.historyRetentionDays || '7');
}
});
function loadDashboard() {
var days = parseInt($('#dashboardRange').val()) || 7;
loadStats(days);
loadHistory();
}
function getAccountFilter() {
return $('#dashboardAccount').val() || '';
}
function loadStats(days) {
ajaxCall('/api/hclouddns/history/stats', {days: days}, function(data) {
if (!data || data.status !== 'ok') {
$('#statTotal, #statCreates, #statUpdates, #statDeletes, #statReverted, #statAvg').text('-');
return;
}
// Populate account filter (preserve selection)
var currentAccount = getAccountFilter();
var $accountSelect = $('#dashboardAccount');
var accounts = data.byAccount || {};
$accountSelect.find('option:not(:first)').remove();
$.each(accounts, function(name) {
$accountSelect.append('<option value="' + escapeHtml(name) + '">' + escapeHtml(name) + '</option>');
});
if (currentAccount) {
$accountSelect.val(currentAccount);
}
// Update tiles
$('#statTotal').text(data.total || 0);
$('#statCreates').text(data.creates || 0);
$('#statUpdates').text(data.updates || 0);
$('#statDeletes').text(data.deletes || 0);
$('#statReverted').text(data.reverted || 0);
$('#statAvg').text(data.avgPerDay || 0);
// Render activity timeline
renderTimeline(data.byDate || {}, days);
// Render action breakdown
renderBreakdown(data.total || 0, data.creates || 0, data.updates || 0, data.deletes || 0);
// Render top zones
renderTopZones(data.byZone || {});
});
}
function renderTimeline(byDate, days) {
var $chart = $('#timelineChart').empty();
var $labels = $('#timelineLabels').empty();
// Generate date range
var dates = [];
var now = new Date();
for (var i = days - 1; i >= 0; i--) {
var d = new Date(now);
d.setDate(d.getDate() - i);
dates.push(d.toISOString().split('T')[0]);
}
// Find max value for scaling
var maxVal = 0;
$.each(dates, function(i, date) {
var entry = byDate[date] || {create: 0, update: 0, delete: 0};
var total = (entry.create || 0) + (entry.update || 0) + (entry.delete || 0);
if (total > maxVal) maxVal = total;
});
if (maxVal === 0) maxVal = 1;
// Render bars
$.each(dates, function(i, date) {
var entry = byDate[date] || {create: 0, update: 0, delete: 0};
var c = entry.create || 0;
var u = entry.update || 0;
var d = entry.delete || 0;
var total = c + u + d;
var cH = total > 0 ? Math.max(2, (c / maxVal) * 100) : 0;
var uH = total > 0 ? Math.max(2, (u / maxVal) * 100) : 0;
var dH = total > 0 ? Math.max(2, (d / maxVal) * 100) : 0;
if (c === 0) cH = 0;
if (u === 0) uH = 0;
if (d === 0) dH = 0;
var shortDate = date.substring(5);
var tooltip = date + ': ' + c + ' create, ' + u + ' update, ' + d + ' delete';
$chart.append(
'<div class="timeline-bar" title="' + tooltip + '">' +
'<div class="timeline-tooltip">' + tooltip + '</div>' +
'<div class="timeline-bar-segment delete" style="height:' + dH + '%;"></div>' +
'<div class="timeline-bar-segment update" style="height:' + uH + '%;"></div>' +
'<div class="timeline-bar-segment create" style="height:' + cH + '%;"></div>' +
'</div>'
);
var showLabel = false;
if (days <= 7) showLabel = true;
else if (days <= 30) showLabel = (i % 3 === 0 || i === dates.length - 1);
else showLabel = (i % 7 === 0 || i === dates.length - 1);
if (showLabel) {
$labels.append('<span>' + shortDate + '</span>');
}
});
}
function renderBreakdown(total, creates, updates, deletes) {
if (total === 0) {
$('#actionBreakdown').html('<div class="text-center text-muted">{{ lang._("No data") }}</div>');
return;
}
var html = '';
var items = [
{label: '{{ lang._("Create") }}', value: creates, cls: 'create'},
{label: '{{ lang._("Update") }}', value: updates, cls: 'update'},
{label: '{{ lang._("Delete") }}', value: deletes, cls: 'delete'}
];
$.each(items, function(i, item) {
var pct = Math.round((item.value / total) * 100);
html += '<div class="breakdown-bar">' +
'<span class="breakdown-label">' + item.label + '</span>' +
'<div class="breakdown-track"><div class="breakdown-fill ' + item.cls + '" style="width:' + pct + '%;">' + (pct > 10 ? pct + '%' : '') + '</div></div>' +
'<span class="breakdown-value">' + item.value + '</span>' +
'</div>';
});
$('#actionBreakdown').html(html);
}
function renderTopZones(byZone) {
var zones = [];
$.each(byZone, function(name, count) {
zones.push({name: name, count: count});
});
zones.sort(function(a, b) { return b.count - a.count; });
zones = zones.slice(0, 5);
if (zones.length === 0) {
$('#topZones').html('<div class="text-center text-muted">{{ lang._("No data") }}</div>');
return;
}
var maxCount = zones[0].count || 1;
var html = '';
$.each(zones, function(i, z) {
var pct = Math.round((z.count / maxCount) * 100);
html += '<div class="top-zone-bar">' +
'<span class="top-zone-name" title="' + z.name + '">' + z.name + '</span>' +
'<div class="top-zone-track"><div class="top-zone-fill" style="width:' + pct + '%;"></div></div>' +
'<span class="top-zone-count">' + z.count + '</span>' +
'</div>';
});
$('#topZones').html(html);
}
function loadHistory() {
var $tbody = $('#historyTable tbody');
$tbody.html('<tr><td colspan="9" class="text-center text-muted"><i class="fa fa-spinner fa-spin"></i> {{ lang._("Loading...") }}</td></tr>');
ajaxCall('/api/hclouddns/history/searchItem', {}, function(data) {
if (!data || !data.rows || data.rows.length === 0) {
allHistoryRows = [];
renderHistoryTable();
return;
}
allHistoryRows = data.rows;
renderHistoryTable();
});
}
function renderHistoryTable() {
var $tbody = $('#historyTable tbody').empty();
var days = parseInt($('#dashboardRange').val()) || 7;
var accountFilter = getAccountFilter();
var cutoff = Math.floor(Date.now() / 1000) - (days * 86400);
// Filter rows by time range and account
var filtered = [];
$.each(allHistoryRows, function(i, row) {
if (row.timestamp < cutoff) return;
if (accountFilter && row.accountName !== accountFilter) return;
filtered.push(row);
});
// Info text
var infoText = filtered.length + ' {{ lang._("entries") }}';
if (filtered.length !== allHistoryRows.length) {
infoText += ' ({{ lang._("filtered from") }} ' + allHistoryRows.length + ' {{ lang._("total") }})';
}
$('#historyTableInfo').text(infoText);
if (filtered.length === 0) {
if (allHistoryRows.length === 0) {
// True empty state
$tbody.html(
'<tr><td colspan="9">' +
'<div class="empty-state">' +
'<i class="fa fa-history"></i>' +
'<p>{{ lang._("No changes recorded yet.") }}</p>' +
'<p class="text-muted" style="font-size:12px;">{{ lang._("Changes appear here automatically when DNS records are modified — through DynDNS updates, failover events, or manual edits on the DNS management page.") }}</p>' +
'</div>' +
'</td></tr>'
);
} else {
// Filtered empty
$tbody.html('<tr><td colspan="9" class="text-center text-muted" style="padding:30px;">{{ lang._("No entries match the selected filters. Try a wider time range or different account.") }}</td></tr>');
}
return;
}
$.each(filtered, function(i, row) {
var actionClass = {create: 'success', update: 'info', delete: 'danger'}[row.action] || 'default';
var actionIcon = {create: 'plus', update: 'pencil', delete: 'trash'}[row.action] || 'circle';
var revertedClass = row.reverted === '1' ? 'text-muted' : '';
var revertedBadge = row.reverted === '1' ? '<span class="label label-default">{{ lang._("Reverted") }}</span>' : '<span class="label label-primary">{{ lang._("Active") }}</span>';
var revertBtn = '';
if (row.reverted !== '1') {
revertBtn = '<button class="btn btn-xs btn-warning revert-btn" data-uuid="' + row.uuid + '" title="{{ lang._("Revert this change") }}"><i class="fa fa-undo"></i></button>';
}
var recordFqdn = row.recordName + '.' + row.zoneName;
var oldVal = row.oldValue || '-';
var newVal = row.newValue || '-';
$tbody.append(
'<tr class="' + revertedClass + '">' +
'<td><small>' + row.timestampFormatted + '</small></td>' +
'<td><span class="label label-' + actionClass + '"><i class="fa fa-' + actionIcon + '"></i> ' + row.action + '</span></td>' +
'<td><span class="record-code">' + escapeHtml(recordFqdn) + '</span></td>' +
'<td><span class="label label-default">' + row.recordType + '</span></td>' +
'<td class="value-cell" title="' + escapeHtml(oldVal) + '"><small>' + escapeHtml(oldVal) + '</small></td>' +
'<td class="value-cell" title="' + escapeHtml(newVal) + '"><small>' + escapeHtml(newVal) + '</small></td>' +
'<td><small>' + escapeHtml(row.accountName || '-') + '</small></td>' +
'<td>' + revertedBadge + '</td>' +
'<td>' + revertBtn + '</td>' +
'</tr>'
);
});
}
function escapeHtml(text) {
if (!text) return '';
return $('<div>').text(text).html();
}
// Load dashboard on page load
loadDashboard();
// Filters trigger re-render
$('#dashboardRange').on('change', function() {
var days = parseInt($(this).val()) || 7;
loadStats(days);
renderHistoryTable(); // client-side re-filter, no extra API call
});
$('#dashboardAccount').on('change', function() {
renderHistoryTable(); // client-side re-filter
});
// Refresh button
$('#refreshDashboardBtn, #refreshHistoryBtn').on('click', function() {
loadDashboard();
});
// Revert history entry
$(document).on('click', '.revert-btn', function() {
var $btn = $(this);
var uuid = $btn.data('uuid');
BootstrapDialog.confirm({
title: '{{ lang._("Revert Change") }}',
message: '{{ lang._("Are you sure you want to revert this DNS change? This will restore the previous value at Hetzner.") }}',
type: BootstrapDialog.TYPE_WARNING,
btnOKLabel: '{{ lang._("Revert") }}',
btnOKClass: 'btn-warning',
callback: function(result) {
if (result) {
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
ajaxCall('/api/hclouddns/history/revert/' + uuid, {_: ''}, function(data) {
if (data && data.status === 'ok') {
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_SUCCESS,
title: '{{ lang._("Change Reverted") }}',
message: data.message || '{{ lang._("The DNS change has been reverted successfully.") }}'
});
loadDashboard();
} else {
$btn.prop('disabled', false).html('<i class="fa fa-undo"></i>');
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_DANGER,
title: '{{ lang._("Revert Failed") }}',
message: data.message || '{{ lang._("Failed to revert the change.") }}'
});
}
});
}
}
});
});
// Cleanup old history entries
$('#cleanupHistoryBtn').click(function() {
BootstrapDialog.confirm({
title: '{{ lang._("Cleanup History") }}',
message: '{{ lang._("Remove entries older than the configured retention period?") }}',
type: BootstrapDialog.TYPE_WARNING,
btnOKLabel: '{{ lang._("Cleanup") }}',
btnOKClass: 'btn-warning',
callback: function(result) {
if (!result) return;
var $btn = $('#cleanupHistoryBtn').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Cleaning...") }}');
ajaxCall('/api/hclouddns/history/cleanup', {_: ''}, function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-trash"></i> {{ lang._("Cleanup Old Entries") }}');
if (data && data.status === 'ok') {
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_SUCCESS,
title: '{{ lang._("Cleanup Complete") }}',
message: data.message || (data.deleted + ' {{ lang._("old entries removed.") }}')
});
loadDashboard();
} else {
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_DANGER,
title: '{{ lang._("Cleanup Failed") }}',
message: data.message || '{{ lang._("Failed to cleanup history.") }}'
});
}
});
}
});
});
// Clear all history
$('#clearAllHistoryBtn').click(function() {
BootstrapDialog.confirm({
title: '{{ lang._("Clear All History") }}',
message: '{{ lang._("Are you sure you want to delete ALL history entries? This cannot be undone.") }}',
type: BootstrapDialog.TYPE_DANGER,
btnOKLabel: '{{ lang._("Clear All") }}',
btnOKClass: 'btn-danger',
callback: function(result) {
if (result) {
ajaxCall('/api/hclouddns/history/clearAll', {_: ''}, function(data) {
if (data && data.status === 'ok') {
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, message: data.message});
loadDashboard();
} else {
BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: data.message || '{{ lang._("Failed to clear history.") }}'});
}
});
}
}
});
});
});
</script>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,985 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Hetzner Cloud DNS - Settings (Accounts)
#}
<style>
/* Import modal styles */
#importModal .zone-item { border: 1px solid #ddd; border-radius: 4px; margin-bottom: 8px; background: #fff; }
#importModal .zone-header { padding: 10px 15px; cursor: pointer; display: flex; align-items: center; background: #f8f9fa; }
#importModal .zone-header:hover { background: #e9ecef; }
#importModal .zone-header .zone-checkbox { margin-right: 10px; }
#importModal .zone-header .zone-name { flex: 1; font-weight: 500; }
#importModal .zone-header .zone-toggle { color: #666; }
#importModal .zone-records { padding: 10px 15px 10px 40px; border-top: 1px solid #eee; display: none; }
#importModal .zone-records.show { display: block; }
#importModal .record-item { padding: 5px 0; display: flex; align-items: center; }
#importModal .record-item label { margin: 0; font-weight: normal; flex: 1; }
#importModal .record-item.existing { opacity: 0.6; background: #f5f5f5; padding: 5px 8px; margin: 2px -8px; border-radius: 3px; }
#importModal .record-item.existing label { color: #888; }
.bg-success { background-color: #dff0d8 !important; transition: background-color 0.3s; }
#importModal .record-type { font-size: 11px; padding: 2px 6px; border-radius: 3px; margin-left: 8px; }
</style>
<!-- General Settings Section -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-cog"></i> {{ lang._('General Settings') }}</h3>
</div>
<div class="panel-body">
{{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }}
<button class="btn btn-primary" id="saveGeneralBtn"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
</div>
</div>
<!-- Accounts Section -->
<div class="panel panel-default" style="margin-top: 20px;">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-key"></i> {{ lang._('API Accounts') }}</h3>
</div>
<div class="panel-body">
<p class="text-muted">{{ lang._('Manage API tokens for Hetzner DNS. Each token provides access to one or more zones.') }}</p>
<table id="grid-accounts" class="table table-condensed table-hover table-striped" data-editDialog="dialogAccount" data-editAlert="accountChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">ID</th>
<th data-column-id="enabled" data-type="boolean" data-formatter="rowtoggle" data-width="6em">{{ lang._('Enabled') }}</th>
<th data-column-id="name" data-type="string">{{ lang._('Name') }}</th>
<th data-column-id="apiType" data-type="string" data-width="10em">{{ lang._('API Type') }}</th>
<th data-column-id="description" data-type="string">{{ lang._('Description') }}</th>
<th data-column-id="commands" data-formatter="commands" data-sortable="false" data-width="10em">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr><td></td><td><button data-action="add" type="button" class="btn btn-xs btn-primary"><i class="fa fa-plus"></i></button></td><td colspan="5"></td></tr>
</tfoot>
</table>
<div class="alert alert-info" id="accountChangeMessage" style="display: none;">{{ lang._('Changes need to be saved.') }}</div>
<hr/>
<button class="btn btn-primary" id="saveAccountsBtn"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
<button class="btn btn-success" id="addTokenBtn"><i class="fa fa-key"></i> {{ lang._('Add Token & Import') }}</button>
</div>
</div>
<!-- Notifications Section -->
<div class="panel panel-default" style="margin-top: 20px;">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-bell"></i> {{ lang._('Notifications') }}</h3>
</div>
<div class="panel-body">
<p class="text-muted">{{ lang._('Get notified when DNS records change, failover events occur, or errors happen.') }}</p>
<div class="row">
<div class="col-md-12">
<div class="checkbox">
<label>
<input type="checkbox" id="notifyEnabled"> <strong>{{ lang._('Enable Notifications') }}</strong>
</label>
</div>
</div>
</div>
<div id="notifySettings" style="display: none; margin-top: 15px;">
<div class="row">
<div class="col-md-12">
<h5>{{ lang._('Notify On:') }}</h5>
<div class="checkbox-inline">
<label><input type="checkbox" id="notifyOnUpdate" checked> {{ lang._('DNS Updates') }}</label>
</div>
<div class="checkbox-inline">
<label><input type="checkbox" id="notifyOnFailover" checked> {{ lang._('Failover') }}</label>
</div>
<div class="checkbox-inline">
<label><input type="checkbox" id="notifyOnFailback" checked> {{ lang._('Failback') }}</label>
</div>
<div class="checkbox-inline">
<label><input type="checkbox" id="notifyOnError" checked> {{ lang._('Errors') }}</label>
</div>
<button class="btn btn-primary btn-xs" id="saveNotifyGlobalBtn" style="margin-left: 15px;"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
</div>
</div>
<hr/>
<div class="row" style="display:flex; flex-wrap:wrap;">
<!-- Ntfy -->
<div class="col-md-4" style="display:flex;">
<div class="well" style="flex:1; display:flex; flex-direction:column;">
<h5><i class="fa fa-bullhorn"></i> {{ lang._('Ntfy') }}
<label class="pull-right" style="font-weight:normal;margin:0;"><input type="checkbox" id="ntfyEnabled"> {{ lang._('Enable') }}</label>
</h5>
<div class="form-group">
<label>{{ lang._('Server URL') }}</label>
<input type="url" class="form-control" id="ntfyServer" value="https://ntfy.sh" placeholder="https://ntfy.sh">
</div>
<div class="form-group">
<label>{{ lang._('Topic') }}</label>
<input type="text" class="form-control" id="ntfyTopic" placeholder="my-ddns-alerts">
</div>
<div class="form-group">
<label>{{ lang._('Priority') }}</label>
<select class="form-control" id="ntfyPriority">
<option value="min">Min (1)</option>
<option value="low">Low (2)</option>
<option value="default" selected>Default (3)</option>
<option value="high">High (4)</option>
<option value="urgent">Urgent (5)</option>
</select>
</div>
<div style="margin-top:auto;">
<div class="btn-group">
<button class="btn btn-primary btn-sm saveChannelBtn" data-channel="ntfy"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
<button class="btn btn-info btn-sm testChannelBtn" data-channel="ntfy"><i class="fa fa-paper-plane"></i> {{ lang._('Test') }}</button>
</div>
<span class="channel-result" id="ntfyResult" style="margin-left: 10px;"></span>
</div>
</div>
</div>
<!-- Email (SMTP) -->
<div class="col-md-4" style="display:flex;">
<div class="well" style="flex:1; display:flex; flex-direction:column;">
<h5><i class="fa fa-envelope"></i> {{ lang._('Email (SMTP)') }}
<label class="pull-right" style="font-weight:normal;margin:0;"><input type="checkbox" id="emailEnabled"> {{ lang._('Enable') }}</label>
</h5>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>{{ lang._('SMTP Server') }}</label>
<input type="text" class="form-control" id="smtpServer" placeholder="smtp.example.com">
</div>
<div class="form-group">
<label>{{ lang._('Port') }}</label>
<input type="number" class="form-control" id="smtpPort" value="587" min="1" max="65535">
</div>
<div class="form-group">
<label>{{ lang._('Username') }} <small class="text-muted">({{ lang._('opt.') }})</small></label>
<input type="text" class="form-control" id="smtpUser" placeholder="">
</div>
<div class="form-group">
<label>{{ lang._('Password') }} <small class="text-muted">({{ lang._('opt.') }})</small></label>
<input type="password" class="form-control" id="smtpPassword" placeholder="">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>{{ lang._('Encryption') }}</label>
<select class="form-control" id="smtpTls">
<option value="starttls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
<option value="none">None</option>
</select>
</div>
<div class="form-group">
<label>{{ lang._('Sender (From)') }}</label>
<input type="email" class="form-control" id="emailFrom" placeholder="hclouddns@firewall.local">
</div>
<div class="form-group">
<label>{{ lang._('Recipient (To)') }}</label>
<input type="email" class="form-control" id="emailTo" placeholder="admin@example.com">
</div>
</div>
</div>
<div style="margin-top:auto;">
<div class="btn-group">
<button class="btn btn-primary btn-sm saveChannelBtn" data-channel="email"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
<button class="btn btn-info btn-sm testChannelBtn" data-channel="email"><i class="fa fa-paper-plane"></i> {{ lang._('Test') }}</button>
</div>
<span class="channel-result" id="emailResult" style="margin-left: 10px;"></span>
</div>
</div>
</div>
<!-- Webhook -->
<div class="col-md-4" style="display:flex;">
<div class="well" style="flex:1; display:flex; flex-direction:column;">
<h5><i class="fa fa-link"></i> {{ lang._('Webhook') }}
<label class="pull-right" style="font-weight:normal;margin:0;"><input type="checkbox" id="webhookEnabled"> {{ lang._('Enable') }}</label>
</h5>
<div class="form-group">
<label>{{ lang._('URL') }}</label>
<input type="url" class="form-control" id="webhookUrl" placeholder="https://example.com/webhook">
</div>
<div class="form-group">
<label>{{ lang._('Method') }}</label>
<select class="form-control" id="webhookMethod">
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</div>
<div style="margin-top:auto;">
<div class="btn-group">
<button class="btn btn-primary btn-sm saveChannelBtn" data-channel="webhook"><i class="fa fa-save"></i> {{ lang._('Save') }}</button>
<button class="btn btn-info btn-sm testChannelBtn" data-channel="webhook"><i class="fa fa-paper-plane"></i> {{ lang._('Test') }}</button>
</div>
<span class="channel-result" id="webhookResult" style="margin-left: 10px;"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Backup / Export Section -->
<div class="panel panel-default" style="margin-top: 20px;">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-download"></i> {{ lang._('Backup / Export') }}</h3>
</div>
<div class="panel-body">
<p class="text-muted">{{ lang._('Export your configuration as JSON for backup or migration. Import to restore settings.') }}</p>
<div class="row" style="display:flex; flex-wrap:wrap;">
<div class="col-md-6" style="display:flex;">
<div class="well" style="flex:1; display:flex; flex-direction:column;">
<h5><i class="fa fa-cloud-download"></i> {{ lang._('Export Configuration') }}</h5>
<p class="small text-muted">{{ lang._('Download current configuration as JSON file.') }}</p>
<div class="checkbox">
<label>
<input type="checkbox" id="exportIncludeTokens"> {{ lang._('Include API tokens (security risk!)') }}
</label>
</div>
<div style="margin-top:auto;">
<button class="btn btn-primary" id="exportConfigBtn"><i class="fa fa-download"></i> {{ lang._('Export') }}</button>
</div>
</div>
</div>
<div class="col-md-6" style="display:flex;">
<div class="well" style="flex:1; display:flex; flex-direction:column;">
<h5><i class="fa fa-cloud-upload"></i> {{ lang._('Import Configuration') }}</h5>
<p class="small text-muted">{{ lang._('Import configuration from a JSON backup file.') }}</p>
<input type="file" id="importConfigFile" accept=".json" style="margin-bottom: 10px;">
<div style="margin-top:auto;">
<button class="btn btn-warning" id="importConfigBtn" disabled><i class="fa fa-upload"></i> {{ lang._('Import') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Import Modal -->
<div class="modal fade" id="importModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title"><i class="fa fa-download"></i> <span id="importSectionTitle">{{ lang._('Add Token & Import DNS Entries') }}</span></h4>
</div>
<div class="modal-body" style="padding: 20px 30px;">
<!-- Step 1: Token Input -->
<div id="importStep1">
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label>{{ lang._('Account Name') }}</label>
<input type="text" class="form-control" id="importAccountName" placeholder="e.g. Production, My Project">
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label>{{ lang._('API Type') }}</label>
<select class="form-control" id="importApiType">
<option value="cloud">Hetzner Cloud API</option>
<option value="dns">Hetzner DNS API (deprecated)</option>
</select>
</div>
</div>
<div class="col-md-5">
<div class="form-group">
<label>{{ lang._('API Token') }}</label>
<div class="input-group">
<input type="password" class="form-control" id="importToken" placeholder="{{ lang._('Paste Hetzner API Token here') }}">
<span class="input-group-btn">
<button class="btn btn-default" type="button" id="toggleImportToken"><i class="fa fa-eye"></i></button>
</span>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-info" id="validateImportToken"><i class="fa fa-check-circle"></i> {{ lang._('Validate Token & Load Zones') }}</button>
<div id="tokenValidationResult" style="margin-top: 10px;"></div>
</div>
<!-- Step 2: Zone/Record Selection -->
<div id="importStep2" style="display: none;">
<div class="alert alert-success">
<i class="fa fa-check"></i> <span id="tokenValidMsg"></span>
</div>
<h5>{{ lang._('Select Zones and Records to Import') }}</h5>
<p class="text-muted small">{{ lang._('Click zones to import all records, or expand to select individual records.') }}</p>
<div id="zonesList" style="max-height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; margin-bottom: 15px;"></div>
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label>{{ lang._('Primary Gateway') }}</label>
<select class="form-control" id="importPrimaryGw"></select>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label>{{ lang._('Failover Gateway') }}</label>
<select class="form-control" id="importFailoverGw"></select>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label>&nbsp;</label>
<div>
<span id="importSelectedCount" class="text-muted"></span>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-success" id="importBtn" disabled><i class="fa fa-download"></i> {{ lang._('Import Selected') }}</button>
<button type="button" class="btn btn-default" id="backToStep1Btn"><i class="fa fa-arrow-left"></i> {{ lang._('Back') }}</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ lang._('Close') }}</button>
</div>
</div>
</div>
</div>
<!-- Account Dialog -->
{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'dialogAccount', 'label': lang._('API Account')]) }}
<script>
$(document).ready(function() {
updateServiceControlUI('hclouddns');
// ==================== GENERAL SETTINGS ====================
var data_get_map = {'frm_general_settings': '/api/hclouddns/settings/get'};
mapDataToFormUI(data_get_map).done(function() {
formatTokenizersUI();
$('.selectpicker').selectpicker('refresh');
});
$('#saveGeneralBtn').click(function() {
saveFormToEndpoint('/api/hclouddns/settings/set', 'frm_general_settings', function() {
ajaxCall('/api/hclouddns/service/reconfigure', {_: ''}, function() {
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, title: 'Success', message: 'Settings saved successfully.'});
});
}, true);
});
// ==================== NOTIFICATIONS ====================
function loadNotificationSettings() {
ajaxCall('/api/hclouddns/settings/get', {}, function(data) {
if (data && data.hclouddns && data.hclouddns.notifications) {
var n = data.hclouddns.notifications;
$('#notifyEnabled').prop('checked', n.enabled === '1').trigger('change');
$('#notifyOnUpdate').prop('checked', n.notifyOnUpdate === '1');
$('#notifyOnFailover').prop('checked', n.notifyOnFailover === '1');
$('#notifyOnFailback').prop('checked', n.notifyOnFailback === '1');
$('#notifyOnError').prop('checked', n.notifyOnError === '1');
$('#emailEnabled').prop('checked', n.emailEnabled === '1');
$('#emailTo').val(n.emailTo || '');
$('#emailFrom').val(n.emailFrom || '');
$('#smtpServer').val(n.smtpServer || '');
$('#smtpPort').val(n.smtpPort || '587');
$('#smtpTls').val(n.smtpTls || 'starttls');
$('#smtpUser').val(n.smtpUser || '');
$('#smtpPassword').val(n.smtpPassword || '');
$('#webhookEnabled').prop('checked', n.webhookEnabled === '1');
$('#webhookUrl').val(n.webhookUrl || '');
$('#webhookMethod').val(n.webhookMethod || 'POST');
$('#ntfyEnabled').prop('checked', n.ntfyEnabled === '1');
$('#ntfyServer').val(n.ntfyServer || 'https://ntfy.sh');
$('#ntfyTopic').val(n.ntfyTopic || '');
$('#ntfyPriority').val(n.ntfyPriority || 'default');
}
});
}
$('#notifyEnabled').change(function() {
$('#notifySettings').toggle($(this).is(':checked'));
});
$('#smtpTls').change(function() {
var portMap = {'starttls': 587, 'ssl': 465, 'none': 25};
$('#smtpPort').val(portMap[$(this).val()] || 587);
});
// Collect all notification settings into POST data
function getAllNotifyData() {
return {
'hclouddns[notifications][enabled]': $('#notifyEnabled').is(':checked') ? '1' : '0',
'hclouddns[notifications][notifyOnUpdate]': $('#notifyOnUpdate').is(':checked') ? '1' : '0',
'hclouddns[notifications][notifyOnFailover]': $('#notifyOnFailover').is(':checked') ? '1' : '0',
'hclouddns[notifications][notifyOnFailback]': $('#notifyOnFailback').is(':checked') ? '1' : '0',
'hclouddns[notifications][notifyOnError]': $('#notifyOnError').is(':checked') ? '1' : '0',
'hclouddns[notifications][emailEnabled]': $('#emailEnabled').is(':checked') ? '1' : '0',
'hclouddns[notifications][emailTo]': $('#emailTo').val(),
'hclouddns[notifications][emailFrom]': $('#emailFrom').val(),
'hclouddns[notifications][smtpServer]': $('#smtpServer').val(),
'hclouddns[notifications][smtpPort]': $('#smtpPort').val(),
'hclouddns[notifications][smtpTls]': $('#smtpTls').val(),
'hclouddns[notifications][smtpUser]': $('#smtpUser').val(),
'hclouddns[notifications][smtpPassword]': $('#smtpPassword').val(),
'hclouddns[notifications][webhookEnabled]': $('#webhookEnabled').is(':checked') ? '1' : '0',
'hclouddns[notifications][webhookUrl]': $('#webhookUrl').val(),
'hclouddns[notifications][webhookMethod]': $('#webhookMethod').val(),
'hclouddns[notifications][ntfyEnabled]': $('#ntfyEnabled').is(':checked') ? '1' : '0',
'hclouddns[notifications][ntfyServer]': $('#ntfyServer').val(),
'hclouddns[notifications][ntfyTopic]': $('#ntfyTopic').val(),
'hclouddns[notifications][ntfyPriority]': $('#ntfyPriority').val()
};
}
// Show inline result next to button
function showChannelResult($span, success, message) {
var icon = success ? '<i class="fa fa-check text-success"></i>' : '<i class="fa fa-times text-danger"></i>';
$span.html(icon + ' <small>' + message + '</small>');
setTimeout(function() { $span.fadeOut(400, function() { $(this).empty().show(); }); }, 8000);
}
// Save global notify toggles (enabled + notify-on checkboxes)
$('#saveNotifyGlobalBtn').click(function() {
var $btn = $(this).prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
ajaxCall('/api/hclouddns/settings/set', getAllNotifyData(), function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-save"></i> Save');
if (data && data.status === 'ok') {
$btn.after('<span class="text-success" style="margin-left:8px;"><i class="fa fa-check"></i> Saved</span>');
setTimeout(function() { $btn.next('span').fadeOut(400, function() { $(this).remove(); }); }, 3000);
}
});
});
// Per-channel Save button
$(document).on('click', '.saveChannelBtn', function() {
var channel = $(this).data('channel');
var $btn = $(this).prop('disabled', true);
var origHtml = $btn.html();
$btn.html('<i class="fa fa-spinner fa-spin"></i>');
var $result = $('#' + channel + 'Result');
ajaxCall('/api/hclouddns/settings/set', getAllNotifyData(), function(data) {
$btn.prop('disabled', false).html(origHtml);
if (data && data.status === 'ok') {
showChannelResult($result, true, 'Saved');
} else {
var msg = 'Save failed';
if (data && data.validations) {
msg = Object.values(data.validations).join(', ');
}
showChannelResult($result, false, msg);
}
});
});
// Per-channel Test button: saves first, then tests
$(document).on('click', '.testChannelBtn', function() {
var channel = $(this).data('channel');
var $btn = $(this).prop('disabled', true);
var origHtml = $btn.html();
$btn.html('<i class="fa fa-spinner fa-spin"></i>');
var $result = $('#' + channel + 'Result');
$result.html('<small class="text-muted"><i class="fa fa-spinner fa-spin"></i> Saving & testing...</small>');
// Save all settings first, then test the specific channel
ajaxCall('/api/hclouddns/settings/set', getAllNotifyData(), function(saveData) {
if (saveData && saveData.status !== 'ok') {
$btn.prop('disabled', false).html(origHtml);
var msg = 'Save failed';
if (saveData && saveData.validations) {
msg = Object.values(saveData.validations).join(', ');
}
showChannelResult($result, false, msg);
return;
}
// Now test the channel
ajaxCall('/api/hclouddns/service/testNotify/' + channel, {}, function(data) {
$btn.prop('disabled', false).html(origHtml);
if (data && data.results && data.results[channel]) {
var r = data.results[channel];
showChannelResult($result, r.success, r.message || (r.success ? 'OK' : 'Failed'));
} else {
var msg = (data && data.message) ? data.message : 'Test failed';
showChannelResult($result, false, msg);
}
});
});
});
// Load notification settings on page load
loadNotificationSettings();
// ==================== BACKUP / EXPORT ====================
$('#exportConfigBtn').click(function() {
var $btn = $(this).prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Exporting...');
var includeTokens = $('#exportIncludeTokens').is(':checked') ? '1' : '0';
ajaxCall('/api/hclouddns/settings/export/' + includeTokens, {}, function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-download"></i> Export');
if (data && data.status === 'ok' && data.export) {
// Download as JSON file
var jsonStr = JSON.stringify(data.export, null, 2);
var blob = new Blob([jsonStr], {type: 'application/json'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'hclouddns-config-' + new Date().toISOString().slice(0,10) + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_SUCCESS,
title: 'Export Complete',
message: 'Configuration exported: ' + data.export.gateways.length + ' gateways, ' +
data.export.accounts.length + ' accounts, ' + data.export.entries.length + ' entries.'
});
} else {
BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, title: 'Export Error', message: 'Failed to export configuration. Please check system logs.'});
}
});
});
// Enable import button when file is selected
$('#importConfigFile').change(function() {
$('#importConfigBtn').prop('disabled', !this.files.length);
});
$('#importConfigBtn').click(function() {
var file = $('#importConfigFile')[0].files[0];
if (!file) return;
var $btn = $(this).prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Importing...');
var reader = new FileReader();
reader.onload = function(e) {
try {
var importData = JSON.parse(e.target.result);
// Show confirmation dialog
var msg = 'This will import configuration from "' + file.name + '":<br><br>';
if (importData.gateways) msg += '• ' + importData.gateways.length + ' gateway(s)<br>';
if (importData.accounts) msg += '• ' + importData.accounts.length + ' account(s)<br>';
if (importData.entries) msg += '• ' + importData.entries.length + ' DNS entry/entries<br>';
msg += '<br><strong class="text-warning">Note:</strong> This will ADD to existing configuration, not replace it.';
BootstrapDialog.confirm({
title: 'Confirm Import',
message: msg,
type: BootstrapDialog.TYPE_WARNING,
btnOKLabel: 'Import',
btnOKClass: 'btn-warning',
callback: function(result) {
if (result) {
ajaxCall('/api/hclouddns/settings/import', {import: JSON.stringify(importData)}, function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-upload"></i> Import');
$('#importConfigFile').val('');
if (data && data.status === 'ok') {
var msg = data.message || 'Import successful.';
if (data.errors && data.errors.length > 0) {
msg += '<br><br><strong>Warnings:</strong><br>' + data.errors.join('<br>');
}
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, title: 'Import Complete', message: msg});
$('#grid-accounts').bootgrid('reload');
} else {
BootstrapDialog.alert({
type: BootstrapDialog.TYPE_DANGER,
title: 'Import Failed',
message: data.message || 'Failed to import configuration.'
});
}
});
} else {
$btn.prop('disabled', false).html('<i class="fa fa-upload"></i> Import');
}
}
});
} catch (err) {
$btn.prop('disabled', false).html('<i class="fa fa-upload"></i> Import');
BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: 'Invalid JSON file: ' + err.message});
}
};
reader.readAsText(file);
});
// ==================== ACCOUNTS ====================
$('#grid-accounts').UIBootgrid({
search: '/api/hclouddns/accounts/searchItem',
get: '/api/hclouddns/accounts/getItem/',
set: '/api/hclouddns/accounts/setItem/',
add: '/api/hclouddns/accounts/addItem/',
del: '/api/hclouddns/accounts/delItem/',
toggle: '/api/hclouddns/accounts/toggleItem/',
options: {
formatters: {
commands: function(col, row) {
return '<button type="button" class="btn btn-xs btn-default command-edit bootgrid-tooltip" data-row-id="' + row.uuid + '" title="Edit"><i class="fa fa-pencil fa-fw"></i></button> ' +
'<button type="button" class="btn btn-xs btn-default command-delete bootgrid-tooltip" data-row-id="' + row.uuid + '" title="Delete"><i class="fa fa-trash-o fa-fw"></i></button>';
}
}
}
});
// Custom delete handler with cascade warning
$(document).on('click', '.command-delete', function(e) {
e.preventDefault();
e.stopPropagation();
var uuid = $(this).data('row-id');
var $row = $(this).closest('tr');
var accountName = $row.find('td:nth-child(3)').text() || 'this account';
// First check if there are associated entries
ajaxCall('/api/hclouddns/accounts/getEntryCount/' + uuid, {}, function(data) {
var entryCount = (data && data.count) ? data.count : 0;
var message = 'Are you sure you want to delete "' + accountName + '"?';
if (entryCount > 0) {
var entryList = (data.entries && data.entries.length > 0)
? '<br><br><small class="text-muted">' + data.entries.slice(0, 5).join(', ') + (data.entries.length > 5 ? '...' : '') + '</small>'
: '';
message = '<strong class="text-danger"><i class="fa fa-exclamation-triangle"></i> Warning!</strong><br><br>' +
'This account has <strong>' + entryCount + ' DNS entries</strong> that will also be deleted:' +
entryList + '<br><br>' +
'Do you want to continue?';
}
BootstrapDialog.confirm({
title: entryCount > 0 ? 'Delete Account & Entries' : 'Delete Account',
message: message,
type: entryCount > 0 ? BootstrapDialog.TYPE_DANGER : BootstrapDialog.TYPE_DEFAULT,
btnCancelLabel: 'Cancel',
btnOKLabel: entryCount > 0 ? 'Delete All' : 'Delete',
btnOKClass: 'btn-danger',
callback: function(result) {
if (result) {
ajaxCall('/api/hclouddns/accounts/delItem/' + uuid, {}, function(delResult) {
if (delResult && delResult.result === 'deleted') {
ajaxCall('/api/hclouddns/settings/set', {}, function() {
$('#grid-accounts').bootgrid('reload');
var msg = 'Account deleted.';
if (delResult.deletedEntries > 0) {
msg = 'Account and ' + delResult.deletedEntries + ' DNS entries deleted.';
}
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, message: msg});
});
} else {
BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, title: 'Delete Error', message: 'Failed to delete account. Please check system logs.'});
}
});
}
}
});
});
});
$('#saveAccountsBtn').click(function() {
saveFormToEndpoint('/api/hclouddns/settings/set', 'frm_general_settings', function() {
$('#grid-accounts').bootgrid('reload');
}, true);
});
// ==================== INLINE IMPORT SECTION ====================
var importZonesData = [];
var importAccountUuid = null;
var importIsExistingAccount = false;
var importToken = '';
var existingEntries = {};
function resetImportSection() {
$('#importAccountName').val('').prop('disabled', false);
$('#importApiType').val('cloud').prop('disabled', false);
$('#importToken').val('').prop('disabled', false);
$('#tokenValidationResult').empty();
$('#importStep2').hide();
$('#importStep1').show();
$('#zonesList').empty();
$('#importBtn').prop('disabled', true);
$('#importSelectedCount').text('');
$('#validateImportToken').show().prop('disabled', false).html('<i class="fa fa-check-circle"></i> Validate Token & Load Zones');
$('#backToStep1Btn').show();
importZonesData = [];
importAccountUuid = null;
importIsExistingAccount = false;
importToken = '';
existingEntries = {};
loadGatewaysForImport();
}
function showImportSection() {
$('#importModal').modal('show');
}
$(document).on('click', '#addTokenBtn', function(e) {
e.preventDefault();
resetImportSection();
$('#importSectionTitle').text('Add Token & Import DNS Entries');
showImportSection();
});
$(document).on('click', '#closeImportSection', function() {
$('#importModal').modal('hide');
});
$(document).on('click', '#backToStep1Btn', function() {
$('#importStep2').hide();
$('#importStep1').show();
});
function openImportForAccount(accountUuid, accountName, apiType) {
resetImportSection();
importAccountUuid = accountUuid;
importIsExistingAccount = true;
$('#importAccountName').val(accountName).prop('disabled', true);
$('#importApiType').val(apiType).prop('disabled', true);
$('#importToken').val('********').prop('disabled', true);
$('#importSectionTitle').text('Import more zones for "' + accountName + '"');
$('#backToStep1Btn').hide();
$('#validateImportToken').hide();
showImportSection();
$('#tokenValidationResult').html('<div class="alert alert-info"><i class="fa fa-spinner fa-spin"></i> Loading zones...</div>');
ajaxCall('/api/hclouddns/entries/getExistingForAccount', {account_uuid: accountUuid}, function(existingData) {
existingEntries = {};
if (existingData && existingData.entries) {
$.each(existingData.entries, function(i, e) {
var key = e.zoneId + ':' + e.recordName + ':' + e.recordType;
existingEntries[key] = true;
});
}
ajaxCall('/api/hclouddns/hetzner/listZonesForAccount', {account_uuid: accountUuid}, function(data) {
if (data && data.status === 'ok' && data.zones && data.zones.length > 0) {
$('#tokenValidMsg').text(data.zones.length + ' zone(s) found.');
$('#tokenValidationResult').empty();
$('#importStep1').hide();
$('#importStep2').show();
renderZonesForImport(data.zones, null, accountUuid);
} else {
var msg = (data && data.message) ? data.message : 'Could not load zones.';
$('#tokenValidationResult').html('<div class="alert alert-danger"><i class="fa fa-times"></i> ' + msg + '</div>');
}
});
});
}
$('#toggleImportToken').click(function() {
var $i = $('#importToken');
if ($i.attr('type') === 'password') { $i.attr('type', 'text'); $(this).html('<i class="fa fa-eye-slash"></i>'); }
else { $i.attr('type', 'password'); $(this).html('<i class="fa fa-eye"></i>'); }
});
function loadGatewaysForImport() {
ajaxCall('/api/hclouddns/gateways/searchItem', {}, function(data) {
var $p = $('#importPrimaryGw').empty();
var $f = $('#importFailoverGw').empty().append('<option value="">None (no failover)</option>');
var gateways = [];
if (data && data.rows) {
gateways = data.rows.filter(function(gw) { return gw.enabled === '1'; });
gateways.sort(function(a, b) { return (parseInt(a.priority) || 99) - (parseInt(b.priority) || 99); });
}
if (gateways.length === 0) {
// No gateways configured - use default option
$p.append('<option value="_default">Default Gateway (auto-detect)</option>');
} else {
$.each(gateways, function(i, gw) {
$p.append('<option value="' + gw.uuid + '">' + gw.name + ' (Prio ' + gw.priority + ')</option>');
$f.append('<option value="' + gw.uuid + '">' + gw.name + ' (Prio ' + gw.priority + ')</option>');
});
if (gateways.length > 0) { $p.val(gateways[0].uuid); }
if (gateways.length > 1) { $f.val(gateways[1].uuid); }
}
updateImportCount();
});
}
$('#validateImportToken').click(function() {
var token = $('#importToken').val().trim();
var name = $('#importAccountName').val().trim();
var apiType = $('#importApiType').val();
if (!name) { $('#tokenValidationResult').html('<div class="alert alert-warning">Please enter an account name.</div>'); return; }
if (!token) { $('#tokenValidationResult').html('<div class="alert alert-warning">Please enter an API token.</div>'); return; }
var $btn = $(this).prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Validating...');
$('#tokenValidationResult').html('<div class="alert alert-info"><i class="fa fa-spinner fa-spin"></i> Validating token and loading zones...</div>');
importToken = token;
ajaxCall('/api/hclouddns/accounts/addItem', {account: {enabled: '1', name: name, apiType: apiType, apiToken: token, description: ''}}, function(addResult) {
if (addResult && addResult.uuid) {
importAccountUuid = addResult.uuid;
ajaxCall('/api/hclouddns/settings/set', {}, function() {
ajaxCall('/api/hclouddns/hetzner/listZonesForAccount', {account_uuid: importAccountUuid}, function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-check-circle"></i> Validate Token & Load Zones');
if (data && data.status === 'ok' && data.zones && data.zones.length > 0) {
$('#tokenValidMsg').text('Token valid! ' + data.zones.length + ' zone(s) found.');
$('#importStep1').hide();
$('#importStep2').show();
renderZonesForImport(data.zones, null, importAccountUuid);
$('#grid-accounts').bootgrid('reload');
} else {
var msg = (data && data.message) ? data.message : 'Invalid token or no zones found.';
$('#tokenValidationResult').html('<div class="alert alert-danger"><i class="fa fa-times"></i> ' + msg + '</div>');
if (importAccountUuid) {
ajaxCall('/api/hclouddns/accounts/delItem/' + importAccountUuid, {}, function() {
ajaxCall('/api/hclouddns/settings/set', {}, function() {});
});
importAccountUuid = null;
}
}
});
});
} else {
$btn.prop('disabled', false).html('<i class="fa fa-check-circle"></i> Validate Token & Load Zones');
var errMsg = (addResult && addResult.validations) ? Object.values(addResult.validations).join(', ') : 'Could not create account.';
$('#tokenValidationResult').html('<div class="alert alert-danger">' + errMsg + '</div>');
}
});
});
function renderZonesForImport(zones, token, accountUuid) {
importZonesData = zones;
var $list = $('#zonesList').empty();
$.each(zones, function(i, zone) {
$list.append(
'<div class="zone-item" data-zone-id="' + zone.id + '" data-zone-name="' + zone.name + '">' +
'<div class="zone-header">' +
'<input type="checkbox" class="zone-checkbox" id="zc-' + zone.id + '">' +
'<label for="zc-' + zone.id + '" class="zone-name">' + zone.name + '</label>' +
'<span class="badge zone-badge" id="badge-' + zone.id + '"><i class="fa fa-spinner fa-spin"></i></span> ' +
'<i class="fa fa-chevron-right zone-toggle"></i>' +
'</div>' +
'<div class="zone-records" id="zr-' + zone.id + '"><i class="fa fa-spinner fa-spin"></i> Loading records...</div>' +
'</div>'
);
var recordsEndpoint = accountUuid ? '/api/hclouddns/hetzner/listRecordsForAccount' : '/api/hclouddns/hetzner/listRecords';
var recordsData = accountUuid ? {account_uuid: accountUuid, zone_id: zone.id} : {token: token, zone_id: zone.id};
ajaxCall(recordsEndpoint, recordsData, function(data) {
var $recs = $('#zr-' + zone.id).empty();
var $badge = $('#badge-' + zone.id);
if (data && data.status === 'ok' && data.records) {
var aRecords = data.records.filter(function(r) { return r.type === 'A' || r.type === 'AAAA'; });
var existingCount = 0;
$.each(aRecords, function(j, rec) {
var key = zone.id + ':' + rec.name + ':' + rec.type;
if (existingEntries[key]) existingCount++;
});
var newCount = aRecords.length - existingCount;
$badge.text(aRecords.length + ' A/AAAA' + (existingCount > 0 ? ' (' + existingCount + ' imported)' : ''));
if (aRecords.length === 0) {
$recs.html('<em class="text-muted">No A/AAAA records in this zone.</em>');
return;
}
$.each(aRecords, function(j, rec) {
var rid = 'rec-' + zone.id + '-' + j;
var typeClass = rec.type === 'A' ? 'label-primary' : 'label-info';
var key = zone.id + ':' + rec.name + ':' + rec.type;
var isExisting = existingEntries[key] || false;
var disabledAttr = isExisting ? ' disabled' : '';
var checkedAttr = isExisting ? ' checked' : '';
var lockIcon = isExisting ? '<i class="fa fa-lock text-muted" title="Already imported"></i> ' : '';
var itemClass = isExisting ? 'record-item existing' : 'record-item';
$recs.append(
'<div class="' + itemClass + '">' +
'<input type="checkbox" class="record-checkbox" id="' + rid + '"' + checkedAttr + disabledAttr +
' data-zone-id="' + zone.id + '" data-zone-name="' + zone.name + '" ' +
'data-record-name="' + rec.name + '" data-record-type="' + rec.type + '" data-ttl="' + rec.ttl + '" data-existing="' + isExisting + '">' +
'<label for="' + rid + '">' + lockIcon + rec.name + '</label>' +
'<span class="record-type label ' + typeClass + '">' + rec.type + '</span>' +
'<span class="text-muted small" style="margin-left:10px;">' + rec.value + '</span>' +
'</div>'
);
});
} else {
$badge.text('error');
$recs.html('<em class="text-danger">Failed to load records.</em>');
}
});
});
}
$(document).on('click', '.zone-header', function(e) {
if ($(e.target).is('input, label')) return;
var $item = $(this).closest('.zone-item');
var $recs = $item.find('.zone-records');
var $icon = $(this).find('.zone-toggle');
$recs.toggleClass('show');
$icon.toggleClass('fa-chevron-right fa-chevron-down');
});
$(document).on('change', '.zone-checkbox', function() {
var $item = $(this).closest('.zone-item');
$item.find('.record-checkbox').prop('checked', $(this).is(':checked'));
updateImportCount();
});
$(document).on('change', '.record-checkbox', function() {
var $item = $(this).closest('.zone-item');
var total = $item.find('.record-checkbox').length;
var checked = $item.find('.record-checkbox:checked').length;
$item.find('.zone-checkbox').prop('checked', checked === total).prop('indeterminate', checked > 0 && checked < total);
updateImportCount();
});
function updateImportCount() {
var newCount = $('.record-checkbox:checked:not(:disabled)').length;
var existingCount = $('.record-checkbox:checked:disabled').length;
var text = '';
if (newCount > 0) text = newCount + ' new record(s) to import';
if (existingCount > 0) text += (text ? ', ' : '') + existingCount + ' already imported';
$('#importSelectedCount').text(text);
// Enable button if records are selected (gateway is always available - either configured or _default)
$('#importBtn').prop('disabled', newCount === 0);
}
$('#importPrimaryGw').change(updateImportCount);
$('#importBtn').click(function() {
var primaryGw = $('#importPrimaryGw').val();
var failoverGw = $('#importFailoverGw').val();
// Handle _default gateway (no gateways configured - will use system default)
if (primaryGw === '_default') {
primaryGw = '';
failoverGw = '';
}
if (primaryGw && primaryGw === failoverGw) { BootstrapDialog.alert({type: BootstrapDialog.TYPE_WARNING, message: 'Failover gateway must differ from primary.'}); return; }
var entries = [];
$('.record-checkbox:checked:not(:disabled)').each(function() {
entries.push({
account: importAccountUuid,
zoneId: $(this).data('zone-id'),
zoneName: $(this).data('zone-name'),
recordName: $(this).data('record-name'),
recordType: $(this).data('record-type'),
ttl: $(this).data('ttl') || 300
});
});
if (entries.length === 0) { BootstrapDialog.alert({type: BootstrapDialog.TYPE_WARNING, message: 'Please select at least one new record to import.'}); return; }
var $btn = $(this).prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Importing...');
ajaxCall('/api/hclouddns/entries/batchAdd', {entries: entries, primaryGateway: primaryGw, failoverGateway: failoverGw}, function(data) {
$btn.prop('disabled', false).html('<i class="fa fa-download"></i> Import');
if (data && data.status === 'ok') {
$('#importModal').modal('hide');
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, message: data.added + ' DNS entry/entries imported successfully!'});
$('#grid-accounts').bootgrid('reload');
} else {
var errMsg = (data && data.message) ? data.message : 'Import failed. Please check system logs.';
BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, title: 'Import Error', message: errMsg});
}
});
});
});
</script>

View file

@ -0,0 +1,393 @@
{#
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
#}
<style>
.zone-accordion .panel-heading {
cursor: pointer;
padding: 10px 15px;
}
.zone-accordion .panel-heading:hover {
background-color: #f5f5f5;
}
.zone-accordion .record-count {
float: right;
color: #666;
}
.zone-accordion .zone-icon {
margin-right: 8px;
}
.record-table {
margin-bottom: 0;
}
.record-table th,
.record-table td {
padding: 6px 10px !important;
}
.record-checkbox {
width: 20px;
}
.selected-records-info {
background-color: #f0f8ff;
border: 1px solid #b8daff;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
}
.gateway-select-row {
background-color: #fafafa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
</style>
<script>
$(document).ready(function() {
var currentToken = '';
var zonesData = [];
var selectedRecords = [];
// Load settings to get API token
function loadApiToken() {
ajaxCall('/api/hclouddns/settings/get', {}, function(data, status) {
if (data && data.hclouddns && data.hclouddns.general && data.hclouddns.general.apiToken) {
currentToken = data.hclouddns.general.apiToken;
$('#apiTokenInput').val(currentToken);
$('#apiTokenStatus').html('<span class="label label-success">{{ lang._("Token configured") }}</span>');
}
});
}
loadApiToken();
// Validate and load zones
$('#loadZonesBtn').click(function() {
var token = $('#apiTokenInput').val();
if (!token) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please enter an API token.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
currentToken = token;
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Loading...") }}');
ajaxCall('/api/hclouddns/hetzner/listZones', {token: token}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-cloud-download"></i> {{ lang._("Load Zones") }}');
if (data && data.status === 'ok' && data.zones) {
zonesData = data.zones;
renderZoneAccordion(data.zones);
$('#apiTokenStatus').html('<span class="label label-success">{{ lang._("Valid") }} - ' + data.zones.length + ' {{ lang._("Zones") }}</span>');
} else {
$('#apiTokenStatus').html('<span class="label label-danger">{{ lang._("Invalid") }}</span>');
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: data && data.message ? data.message : "{{ lang._('Failed to load zones.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
});
// Save API token to settings
$('#saveTokenBtn').click(function() {
var token = $('#apiTokenInput').val();
ajaxCall('/api/hclouddns/settings/set', {hclouddns: {general: {apiToken: token}}}, function(data, status) {
if (data && data.status === 'ok') {
BootstrapDialog.alert({
title: "{{ lang._('Success') }}",
message: "{{ lang._('API token saved.') }}",
type: BootstrapDialog.TYPE_SUCCESS
});
}
});
});
function renderZoneAccordion(zones) {
var $container = $('#zoneAccordion');
$container.empty();
if (zones.length === 0) {
$container.html('<div class="alert alert-info">{{ lang._("No zones found.") }}</div>');
return;
}
$.each(zones, function(i, zone) {
var panelId = 'zone-' + zone.id;
var panelHtml = '<div class="panel panel-default">' +
'<div class="panel-heading" data-toggle="collapse" data-target="#' + panelId + '" data-zone-id="' + zone.id + '" data-zone-name="' + zone.name + '">' +
'<span class="zone-icon fa fa-chevron-right"></span>' +
'<strong>' + zone.name + '</strong>' +
'<span class="record-count">' + zone.records_count + ' {{ lang._("Records") }}</span>' +
'</div>' +
'<div id="' + panelId + '" class="panel-collapse collapse">' +
'<div class="panel-body">' +
'<div class="text-center"><i class="fa fa-spinner fa-spin"></i> {{ lang._("Loading records...") }}</div>' +
'</div>' +
'</div>' +
'</div>';
$container.append(panelHtml);
});
// Handle accordion expand
$container.find('.panel-heading').on('click', function() {
var $icon = $(this).find('.zone-icon');
var $collapse = $(this).next('.panel-collapse');
var zoneId = $(this).data('zone-id');
var zoneName = $(this).data('zone-name');
if ($collapse.hasClass('in')) {
$icon.removeClass('fa-chevron-down').addClass('fa-chevron-right');
} else {
$icon.removeClass('fa-chevron-right').addClass('fa-chevron-down');
// Load records if not already loaded
if ($collapse.find('.record-table').length === 0) {
loadZoneRecords(zoneId, zoneName, $collapse.find('.panel-body'));
}
}
});
}
function loadZoneRecords(zoneId, zoneName, $container) {
ajaxCall('/api/hclouddns/hetzner/listRecords', {token: currentToken, zone_id: zoneId}, function(data, status) {
if (data && data.status === 'ok' && data.records) {
var records = data.records.filter(function(r) {
return r.type === 'A' || r.type === 'AAAA';
});
if (records.length === 0) {
$container.html('<div class="alert alert-info">{{ lang._("No A/AAAA records found in this zone.") }}</div>');
return;
}
var tableHtml = '<table class="table table-condensed table-hover record-table">' +
'<thead><tr>' +
'<th class="record-checkbox"><input type="checkbox" class="select-all-zone" data-zone-id="' + zoneId + '"></th>' +
'<th>{{ lang._("Name") }}</th>' +
'<th>{{ lang._("Type") }}</th>' +
'<th>{{ lang._("Value") }}</th>' +
'<th>{{ lang._("TTL") }}</th>' +
'</tr></thead><tbody>';
$.each(records, function(i, record) {
var recordData = JSON.stringify({
zoneId: zoneId,
zoneName: zoneName,
recordId: record.id,
recordName: record.name,
recordType: record.type,
value: record.value,
ttl: record.ttl
}).replace(/"/g, '&quot;');
tableHtml += '<tr>' +
'<td class="record-checkbox"><input type="checkbox" class="record-select" data-record="' + recordData + '"></td>' +
'<td><code>' + record.name + '</code></td>' +
'<td><span class="label label-' + (record.type === 'A' ? 'primary' : 'info') + '">' + record.type + '</span></td>' +
'<td>' + record.value + '</td>' +
'<td>' + record.ttl + 's</td>' +
'</tr>';
});
tableHtml += '</tbody></table>';
$container.html(tableHtml);
} else {
$container.html('<div class="alert alert-danger">{{ lang._("Failed to load records.") }}</div>');
}
});
}
// Select all in zone
$(document).on('change', '.select-all-zone', function() {
var zoneId = $(this).data('zone-id');
var isChecked = $(this).is(':checked');
$(this).closest('table').find('.record-select').prop('checked', isChecked);
updateSelectedCount();
});
// Individual record selection
$(document).on('change', '.record-select', function() {
updateSelectedCount();
});
function updateSelectedCount() {
selectedRecords = [];
$('.record-select:checked').each(function() {
var recordData = $(this).data('record');
if (typeof recordData === 'string') {
recordData = JSON.parse(recordData.replace(/&quot;/g, '"'));
}
selectedRecords.push(recordData);
});
if (selectedRecords.length > 0) {
$('#selectedInfo').html('<strong>' + selectedRecords.length + '</strong> {{ lang._("record(s) selected") }}');
$('#addSelectedBtn').prop('disabled', false);
} else {
$('#selectedInfo').html('{{ lang._("No records selected") }}');
$('#addSelectedBtn').prop('disabled', true);
}
}
// Load gateways for selection
function loadGateways() {
ajaxCall('/api/hclouddns/gateways/searchItem', {}, function(data, status) {
if (data && data.rows) {
var $primary = $('#primaryGatewaySelect');
var $failover = $('#failoverGatewaySelect');
$primary.empty().append('<option value="">{{ lang._("-- Select Gateway --") }}</option>');
$failover.empty().append('<option value="">{{ lang._("None (no failover)") }}</option>');
$.each(data.rows, function(i, gw) {
if (gw.enabled === '1') {
$primary.append('<option value="' + gw.uuid + '">' + gw.name + ' (' + gw.interface + ')</option>');
$failover.append('<option value="' + gw.uuid + '">' + gw.name + ' (' + gw.interface + ')</option>');
}
});
$primary.selectpicker('refresh');
$failover.selectpicker('refresh');
}
});
}
loadGateways();
// Add selected records
$('#addSelectedBtn').click(function() {
var primaryGateway = $('#primaryGatewaySelect').val();
var failoverGateway = $('#failoverGatewaySelect').val();
var ttl = parseInt($('#ttlInput').val()) || 300;
if (!primaryGateway) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please select a primary gateway.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
if (selectedRecords.length === 0) {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: "{{ lang._('Please select at least one record.') }}",
type: BootstrapDialog.TYPE_WARNING
});
return;
}
var $btn = $(this);
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> {{ lang._("Adding...") }}');
ajaxCall('/api/hclouddns/entries/batchAdd', {
entries: selectedRecords,
primaryGateway: primaryGateway,
failoverGateway: failoverGateway,
ttl: ttl
}, function(data, status) {
$btn.prop('disabled', false).html('<i class="fa fa-plus"></i> {{ lang._("Add Selected Records") }}');
if (data && data.status === 'ok') {
BootstrapDialog.alert({
title: "{{ lang._('Success') }}",
message: data.added + ' {{ lang._("record(s) added successfully.") }}',
type: BootstrapDialog.TYPE_SUCCESS
});
// Clear selections
$('.record-select').prop('checked', false);
$('.select-all-zone').prop('checked', false);
updateSelectedCount();
} else {
BootstrapDialog.alert({
title: "{{ lang._('Error') }}",
message: data && data.message ? data.message : "{{ lang._('Failed to add records.') }}",
type: BootstrapDialog.TYPE_DANGER
});
}
});
});
});
</script>
<div class="tab-content content-box">
<div id="zones" class="tab-pane fade in active">
<div class="content-box-main">
<div class="col-md-12">
<h2>{{ lang._('Zone Selection') }}</h2>
<p class="text-muted">{{ lang._('Select DNS records from your Hetzner zones to manage with dynamic DNS.') }}</p>
</div>
<!-- API Token Section -->
<div class="col-md-12">
<div class="form-group">
<label>{{ lang._('Hetzner DNS API Token') }}</label>
<div class="input-group">
<input type="password" class="form-control" id="apiTokenInput" placeholder="{{ lang._('Enter your Hetzner DNS API token') }}">
<span class="input-group-btn">
<button type="button" class="btn btn-default" id="saveTokenBtn"><i class="fa fa-save"></i></button>
<button type="button" class="btn btn-primary" id="loadZonesBtn"><i class="fa fa-cloud-download"></i> {{ lang._('Load Zones') }}</button>
</span>
</div>
<span id="apiTokenStatus" class="help-block"></span>
</div>
</div>
<!-- Gateway Selection -->
<div class="col-md-12">
<div class="gateway-select-row">
<div class="row">
<div class="col-md-4">
<label>{{ lang._('Primary Gateway') }}</label>
<select id="primaryGatewaySelect" class="selectpicker" data-live-search="true" data-width="100%">
<option value="">{{ lang._('-- Select Gateway --') }}</option>
</select>
</div>
<div class="col-md-4">
<label>{{ lang._('Failover Gateway') }}</label>
<select id="failoverGatewaySelect" class="selectpicker" data-live-search="true" data-width="100%">
<option value="">{{ lang._('None (no failover)') }}</option>
</select>
</div>
<div class="col-md-2">
<label>{{ lang._('TTL') }}</label>
<input type="number" class="form-control" id="ttlInput" value="300" min="60" max="86400">
</div>
<div class="col-md-2">
<label>&nbsp;</label>
<button type="button" class="btn btn-success btn-block" id="addSelectedBtn" disabled>
<i class="fa fa-plus"></i> {{ lang._('Add Selected') }}
</button>
</div>
</div>
</div>
</div>
<!-- Selected Records Info -->
<div class="col-md-12">
<div class="selected-records-info">
<span id="selectedInfo">{{ lang._('No records selected') }}</span>
</div>
</div>
<!-- Zone Accordion -->
<div class="col-md-12">
<div id="zoneAccordion" class="zone-accordion panel-group">
<div class="alert alert-info">
<i class="fa fa-info-circle"></i> {{ lang._('Enter your API token and click "Load Zones" to see available zones.') }}
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -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 <token> <zone_id> <name> <type> <value> <ttl>'
}))
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()

View file

@ -0,0 +1,70 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Create a new DNS zone at Hetzner
"""
import sys
import json
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from hcloud_api import HCloudAPI
def main():
# Expected args: token zone_name
if len(sys.argv) < 3:
print(json.dumps({
'status': 'error',
'message': 'Usage: create_zone.py <token> <zone_name>'
}))
sys.exit(1)
token = sys.argv[1].strip()
zone_name = sys.argv[2].strip().lower()
if not all([token, zone_name]):
print(json.dumps({
'status': 'error',
'message': 'Missing required parameters'
}))
sys.exit(1)
# Basic domain name validation
if not all(c.isalnum() or c in '.-' for c in zone_name) or '.' not in zone_name:
print(json.dumps({
'status': 'error',
'message': f'Invalid zone name: {zone_name}'
}))
sys.exit(1)
api = HCloudAPI(token)
try:
success, message, zone_id = api.create_zone(zone_name)
if success:
print(json.dumps({
'status': 'ok',
'message': f'Zone {zone_name} created successfully',
'zone_id': zone_id,
'zone_name': zone_name
}))
sys.exit(0)
else:
print(json.dumps({
'status': 'error',
'message': f'Failed to create zone: {message}'
}))
sys.exit(1)
except Exception as e:
print(json.dumps({
'status': 'error',
'message': str(e)
}))
sys.exit(1)
if __name__ == '__main__':
main()

View file

@ -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 <token> <zone_id> <name> <type>'
}))
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()

View file

@ -0,0 +1,488 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
DNS Health Check for HCloudDNS zones.
Checks NS delegation, SOA consistency, MX reachability, missing security records,
and CNAME at apex.
"""
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from hcloud_api import HCloudAPI
HETZNER_NAMESERVERS = [
'213.133.100.98', # hydrogen.ns.hetzner.com
'88.198.229.192', # oxygen.ns.hetzner.com
'193.47.99.3', # helium.ns.hetzner.de
]
HETZNER_NS_NAMES = [
'hydrogen.ns.hetzner.com',
'oxygen.ns.hetzner.com',
'helium.ns.hetzner.de',
]
def dns_query(fqdn, rdtype, nameserver=None, timeout=5):
"""Query DNS records. Uses dnspython, falls back to drill."""
results = []
try:
import dns.resolver
import dns.rdatatype
resolver = dns.resolver.Resolver(configure=False)
if nameserver:
resolver.nameservers = [nameserver]
resolver.lifetime = timeout
try:
answer = resolver.resolve(fqdn, dns.rdatatype.from_text(rdtype))
for rdata in answer:
results.append(str(rdata))
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.NoNameservers, dns.exception.Timeout):
pass
except ImportError:
import subprocess
cmd = ['drill']
if nameserver:
cmd.append(f'@{nameserver}')
cmd.extend([fqdn, rdtype])
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
if proc.returncode == 0:
in_answer = False
for line in proc.stdout.splitlines():
if line.strip() == ';; ANSWER SECTION:':
in_answer = True
continue
if in_answer and line.strip() and not line.startswith(';;'):
parts = line.split()
if len(parts) >= 5:
results.append(parts[-1])
elif in_answer and (line.startswith(';;') or not line.strip()):
break
except Exception:
pass
return results
def dns_query_full(fqdn, rdtype, nameserver=None, timeout=5):
"""Query DNS returning full record strings (for SOA etc)."""
results = []
try:
import dns.resolver
import dns.rdatatype
resolver = dns.resolver.Resolver(configure=False)
if nameserver:
resolver.nameservers = [nameserver]
resolver.lifetime = timeout
try:
answer = resolver.resolve(fqdn, dns.rdatatype.from_text(rdtype))
for rdata in answer:
results.append(str(rdata))
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.NoNameservers, dns.exception.Timeout):
pass
except ImportError:
return dns_query(fqdn, rdtype, nameserver, timeout)
return results
def check_ns_delegation(zone_name):
"""Check if NS delegation points to Hetzner nameservers."""
ns_records = dns_query(zone_name, 'NS')
ns_lower = [ns.rstrip('.').lower() for ns in ns_records]
hetzner_found = 0
for hns in HETZNER_NS_NAMES:
if hns.lower() in ns_lower:
hetzner_found += 1
if hetzner_found >= 3:
return {
'name': 'NS Delegation',
'status': 'pass',
'message': f'All {hetzner_found} Hetzner nameservers delegated',
'details': ns_lower
}
elif hetzner_found > 0:
return {
'name': 'NS Delegation',
'status': 'warn',
'message': f'Only {hetzner_found}/3 Hetzner nameservers found',
'details': ns_lower
}
elif len(ns_records) > 0:
return {
'name': 'NS Delegation',
'status': 'warn',
'message': f'NS records found but not pointing to Hetzner ({", ".join(ns_lower[:3])})',
'details': ns_lower
}
else:
return {
'name': 'NS Delegation',
'status': 'fail',
'message': 'No NS records found - domain may not be delegated',
'details': []
}
def check_soa_consistency(zone_name):
"""Check if SOA serial is consistent across all nameservers."""
serials = {}
for ns in HETZNER_NAMESERVERS:
soa_records = dns_query_full(zone_name, 'SOA', ns)
if soa_records:
parts = soa_records[0].split()
if len(parts) >= 3:
serials[ns] = parts[2]
if not serials:
return {
'name': 'SOA Consistency',
'status': 'warn',
'message': 'Could not query SOA from nameservers',
'details': []
}
unique_serials = set(serials.values())
if len(unique_serials) == 1:
serial = list(unique_serials)[0]
return {
'name': 'SOA Consistency',
'status': 'pass',
'message': f'Serial {serial} consistent across {len(serials)} nameservers',
'details': serials
}
else:
return {
'name': 'SOA Consistency',
'status': 'warn',
'message': f'Serial mismatch: {", ".join(unique_serials)}',
'details': serials
}
def check_mx_records(zone_name, records):
"""Check if MX records exist and are resolvable."""
mx_records = [r for r in records if r.get('type') == 'MX']
if not mx_records:
return {
'name': 'MX Records',
'status': 'pass',
'message': 'No MX records (domain may not handle email)',
'details': []
}
resolvable = 0
details = []
for mx in mx_records:
value = mx.get('value', '')
parts = value.split()
hostname = parts[-1] if parts else value
hostname = hostname.rstrip('.')
a_records = dns_query(hostname, 'A')
aaaa_records = dns_query(hostname, 'AAAA')
if a_records or aaaa_records:
resolvable += 1
details.append(f'{hostname}: OK')
else:
details.append(f'{hostname}: unresolvable')
if resolvable == len(mx_records):
return {
'name': 'MX Records',
'status': 'pass',
'message': f'{len(mx_records)} MX record(s), all resolvable',
'details': details
}
else:
return {
'name': 'MX Records',
'status': 'warn',
'message': f'{resolvable}/{len(mx_records)} MX records resolvable',
'details': details
}
def check_spf_record(records):
"""Check if SPF record exists."""
for r in records:
if r.get('type') == 'TXT':
val = r.get('value', '').strip().strip('"').strip("'")
if val.lower().startswith('v=spf1'):
return {
'name': 'SPF Record',
'status': 'pass',
'message': 'SPF record found',
'details': [val]
}
return {
'name': 'SPF Record',
'status': 'warn',
'message': 'No SPF record found - email spoofing possible',
'details': []
}
def check_dmarc_record(zone_name, records):
"""Check if DMARC record exists."""
for r in records:
if r.get('type') == 'TXT' and r.get('name', '') == '_dmarc':
val = r.get('value', '').strip().strip('"').strip("'")
if val.lower().startswith('v=dmarc1'):
return {
'name': 'DMARC Record',
'status': 'pass',
'message': 'DMARC record found',
'details': [val]
}
return {
'name': 'DMARC Record',
'status': 'warn',
'message': 'No DMARC record found - email authentication incomplete',
'details': []
}
def check_caa_record(records):
"""Check if CAA record exists."""
caa_records = [r for r in records if r.get('type') == 'CAA']
if caa_records:
details = [r.get('value', '') for r in caa_records]
return {
'name': 'CAA Record',
'status': 'pass',
'message': f'{len(caa_records)} CAA record(s) found',
'details': details
}
return {
'name': 'CAA Record',
'status': 'warn',
'message': 'No CAA record - any CA can issue certificates',
'details': []
}
def check_cname_at_apex(records):
"""Check if there's a CNAME at the zone apex (invalid)."""
for r in records:
if r.get('type') == 'CNAME' and r.get('name', '') == '@':
return {
'name': 'CNAME at Apex',
'status': 'fail',
'message': 'CNAME at zone apex detected - this breaks DNS!',
'details': [r.get('value', '')]
}
return {
'name': 'CNAME at Apex',
'status': 'pass',
'message': 'No CNAME at zone apex',
'details': []
}
def run_health_check(token, zone_id, zone_name=None):
"""Run all health checks for a zone."""
api = HCloudAPI(token)
ALL_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'SOA']
records = api.list_records(zone_id, ALL_TYPES)
if zone_name is None:
zones = api.list_zones()
for z in zones:
if z.get('id') == zone_id:
zone_name = z.get('name', zone_id)
break
else:
zone_name = zone_id
checks = []
checks.append(check_ns_delegation(zone_name))
checks.append(check_soa_consistency(zone_name))
checks.append(check_mx_records(zone_name, records))
checks.append(check_spf_record(records))
checks.append(check_dmarc_record(zone_name, records))
checks.append(check_caa_record(records))
checks.append(check_cname_at_apex(records))
# DNSSEC check
dnssec = check_dnssec(zone_name)
if dnssec['signed'] and dnssec['delegated']:
checks.append({
'name': 'DNSSEC',
'status': 'pass',
'message': f'DNSSEC active ({dnssec["dnskey_count"]} DNSKEY, DS delegated)',
'details': dnssec['ds_records']
})
elif dnssec['signed']:
checks.append({
'name': 'DNSSEC',
'status': 'warn',
'message': 'Zone is signed but no DS record at parent (not fully delegated)',
'details': []
})
else:
checks.append({
'name': 'DNSSEC',
'status': 'warn',
'message': 'DNSSEC not enabled (optional but recommended)',
'details': []
})
score = sum(1 for c in checks if c['status'] == 'pass')
max_score = len(checks)
return {
'status': 'ok',
'zone': zone_name,
'checks': checks,
'score': score,
'maxScore': max_score
}
def check_dnssec(zone_name):
"""Check DNSSEC status for a zone via DNS queries."""
result = {
'signed': False,
'delegated': False,
'dnskey_count': 0,
'ds_records': []
}
# Check for DNSKEY records (zone is signed)
dnskeys = dns_query(zone_name, 'DNSKEY')
if dnskeys:
result['signed'] = True
result['dnskey_count'] = len(dnskeys)
# Check for DS records at parent (delegation)
ds_records = dns_query(zone_name, 'DS')
if ds_records:
result['delegated'] = True
result['ds_records'] = ds_records
return result
def run_dnssec_check(zone_name):
"""Run DNSSEC check for a zone."""
dnssec = check_dnssec(zone_name)
return {
'status': 'ok',
'zone': zone_name,
'dnssec': dnssec
}
def run_propagation_check(token, zone_id):
"""Check propagation for all records in a zone."""
api = HCloudAPI(token)
ALL_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']
records = api.list_records(zone_id, ALL_TYPES)
zones = api.list_zones()
zone_name = zone_id
for z in zones:
if z.get('id') == zone_id:
zone_name = z.get('name', zone_id)
break
results = []
for rec in records:
rec_name = rec.get('name', '@')
rec_type = rec.get('type', 'A')
expected = rec.get('value', '')
# Skip SOA and NS
if rec_type in ('SOA', 'NS'):
continue
fqdn = f"{rec_name}.{zone_name}" if rec_name != '@' else zone_name
ns_results = {}
for ns in HETZNER_NAMESERVERS:
answers = dns_query(fqdn, rec_type, ns)
if answers:
ns_results[ns] = answers[0]
else:
ns_results[ns] = None
propagated = any(
val is not None and val.rstrip('.') == expected.rstrip('.')
for val in ns_results.values()
)
# For TXT/MX records with quotes or complex values, be more lenient
if not propagated and rec_type in ('TXT', 'MX', 'CAA'):
propagated = any(
val is not None
for val in ns_results.values()
)
results.append({
'name': rec_name,
'type': rec_type,
'expected': expected,
'nsResults': ns_results,
'propagated': propagated
})
return {
'status': 'ok',
'zone': zone_name,
'records': results,
'total': len(results),
'propagated': sum(1 for r in results if r['propagated'])
}
def main():
if len(sys.argv) < 3:
print(json.dumps({
'status': 'error',
'message': 'Usage: dns_health_check.py <mode> <token> <zone_id> [zone_name]'
}))
sys.exit(1)
mode = sys.argv[1].strip()
token = sys.argv[2].strip()
if mode == 'health':
if len(sys.argv) < 4:
print(json.dumps({'status': 'error', 'message': 'zone_id required'}))
sys.exit(1)
zone_id = sys.argv[3].strip()
zone_name = sys.argv[4].strip() if len(sys.argv) > 4 else None
result = run_health_check(token, zone_id, zone_name)
elif mode == 'propagation':
if len(sys.argv) < 4:
print(json.dumps({'status': 'error', 'message': 'zone_id required'}))
sys.exit(1)
zone_id = sys.argv[3].strip()
result = run_propagation_check(token, zone_id)
elif mode == 'dnssec':
if len(sys.argv) < 4:
print(json.dumps({'status': 'error', 'message': 'zone_name required'}))
sys.exit(1)
zone_name = sys.argv[3].strip()
result = run_dnssec_check(zone_name)
else:
result = {'status': 'error', 'message': f'Unknown mode: {mode}'}
print(json.dumps(result))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,498 @@
#!/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 tempfile
# 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 write_state_file(filepath, content, is_json=True):
"""Atomically write state file with 0600 permissions."""
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(filepath), prefix='.hclouddns_')
try:
with os.fdopen(fd, 'w') as f:
if is_json:
json.dump(content, f, indent=2)
else:
f.write(content)
os.chmod(tmp, 0o600)
os.rename(tmp, filepath)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
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:
write_state_file(STATE_FILE, state)
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
request = urllib.request.Request(url, headers={'User-Agent': 'OPNsense-HCloudDNS/2.1'})
with urllib.request.urlopen(request, timeout=10) 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
HETZNER_NAMESERVERS = [
'213.133.100.98', # hydrogen.ns.hetzner.com
'88.198.229.192', # oxygen.ns.hetzner.com
'193.47.99.3', # helium.ns.hetzner.de
]
def verify_dns_propagation(record_name, zone_name, record_type, expected_ip,
nameservers=None, timeout=5):
"""Query authoritative Hetzner nameservers to verify DNS propagation.
Uses dnspython (available on OPNsense via py-dnspython) for direct queries.
Falls back to drill (FreeBSD) if dnspython is not available.
Returns:
dict with 'propagated' (bool), 'results' (ns->ip), 'errors' (ns->error)
"""
if nameservers is None:
nameservers = HETZNER_NAMESERVERS
fqdn = f"{record_name}.{zone_name}" if record_name != '@' else zone_name
results = {}
errors = {}
# Try dnspython first (preferred, available on OPNsense)
try:
import dns.resolver
import dns.rdatatype
rdtype = dns.rdatatype.from_text(record_type)
for ns in nameservers:
try:
resolver = dns.resolver.Resolver(configure=False)
resolver.nameservers = [ns]
resolver.lifetime = timeout
answer = resolver.resolve(fqdn, rdtype)
for rdata in answer:
results[ns] = str(rdata)
break # first answer
except dns.resolver.NXDOMAIN:
errors[ns] = 'NXDOMAIN'
except dns.resolver.NoAnswer:
errors[ns] = 'no answer'
except dns.resolver.NoNameservers:
errors[ns] = 'no nameservers'
except dns.exception.Timeout:
errors[ns] = 'timeout'
except Exception as e:
errors[ns] = str(e)
except ImportError:
# Fallback to drill (available on FreeBSD/OPNsense via ldns)
for ns in nameservers:
try:
cmd = ['drill', f'@{ns}', fqdn, record_type]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
if proc.returncode == 0:
# Parse drill output: look for answer section
in_answer = False
for line in proc.stdout.splitlines():
if line.strip() == ';; ANSWER SECTION:':
in_answer = True
continue
if in_answer and line.strip() and not line.startswith(';;'):
parts = line.split()
if len(parts) >= 5:
results[ns] = parts[-1]
break
elif in_answer and (line.startswith(';;') or not line.strip()):
break
if ns not in results:
errors[ns] = 'empty response'
else:
errors[ns] = proc.stderr.strip() or 'drill failed'
except subprocess.TimeoutExpired:
errors[ns] = 'timeout'
except Exception as e:
errors[ns] = str(e)
propagated = any(ip == expected_ip for ip in results.values())
return {
'propagated': propagated,
'results': results,
'errors': errors
}
def get_opnsense_gateway_status():
"""Query OPNsense's dpinger-based gateway status and gateway-to-interface mapping.
Returns a dict mapping OPNsense interface name (e.g. 'wan', 'opt1') to status string.
OPNsense status values: 'none' = online, 'down', 'force_down', 'loss', 'delay', etc.
"""
iface_status = {}
try:
# Get gateway details with interface mapping
gw_details = subprocess.run(
['php', '-r', """
require_once 'config.inc';
require_once 'util.inc';
require_once 'interfaces.inc';
require_once 'plugins.inc.d/dpinger.inc';
$status = dpinger_status();
$gws = (new \\OPNsense\\Routing\\Gateways())->gatewaysIndexedByName();
$result = [];
foreach ($gws as $name => $gw) {
$s = isset($status[$name]) ? strtolower($status[$name]['status']) : 'none';
$iface = isset($gw['interface']) ? $gw['interface'] : '';
$proto = isset($gw['ipprotocol']) ? $gw['ipprotocol'] : 'inet';
$result[] = ['name' => $name, 'interface' => $iface, 'ipprotocol' => $proto, 'status' => $s];
}
echo json_encode($result);
"""],
capture_output=True, text=True, timeout=10
)
if gw_details.returncode == 0 and gw_details.stdout.strip():
gateways = json.loads(gw_details.stdout)
for gw in gateways:
iface = gw.get('interface', '')
proto = gw.get('ipprotocol', 'inet')
status = gw.get('status', 'none')
if not iface:
continue
# Only use inet (IPv4) gateways for status matching
# (avoid overwriting with inet6 status for same interface)
if proto == 'inet':
iface_status[iface] = status
elif iface not in iface_status:
iface_status[iface] = status
except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError) as e:
sys.stderr.write(f"Error querying OPNsense gateway status: {e}\n")
return iface_status
def is_gateway_up(interface, opnsense_status):
"""Check if a gateway is up based on OPNsense's dpinger status for its interface.
OPNsense reports status='none' for healthy gateways.
Any other value (force_down, down, loss, delay, etc.) means degraded/down.
"""
status = opnsense_status.get(interface)
if status is None:
# Interface not found in OPNsense gateways — assume up
return True
return status == 'none'
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
interface = gateway_config.get('interface', '')
opnsense_status = get_opnsense_gateway_status()
is_healthy = is_gateway_up(interface, opnsense_status)
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()
# Query OPNsense's own gateway status once for all gateways
opnsense_status = get_opnsense_gateway_status()
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
name = gw.findtext('name', '')
interface = gw.findtext('interface', '')
checkip_method = gw.findtext('checkipMethod', 'web_ipify')
# 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)
# Use OPNsense's dpinger-based gateway status (matched by interface)
status = 'up' if is_gateway_up(interface, opnsense_status) 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))
elif action == 'propagation':
if len(sys.argv) < 6:
print(json.dumps({'status': 'error',
'message': 'Usage: propagation <record_name> <zone_name> <record_type> <expected_ip>'}))
sys.exit(1)
record_name = sys.argv[2]
zone_name = sys.argv[3]
record_type = sys.argv[4]
expected_ip = sys.argv[5]
result = verify_dns_propagation(record_name, zone_name, record_type, expected_ip)
result['status'] = 'ok'
print(json.dumps(result))
else:
print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'}))
sys.exit(1)
if __name__ == '__main__':
main()

View file

@ -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 <zone_id> <record_name> <record_type>'}))
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()

View file

@ -0,0 +1,106 @@
#!/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 DNS API wrapper for HCloudDNS OPNsense plugin.
Uses HetznerCloudAPIv2 for Cloud API and HetznerLegacyAPI for deprecated dns.hetzner.com.
"""
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
HetznerLegacyAPI,
HetznerAPIError
)
from hetzner_api_v2 import ( # noqa: E402
HetznerCloudAPIv2,
create_api_v2
)
# Re-export for backward compatibility
HCloudAPIError = HetznerAPIError
class HCloudAPI:
"""
Wrapper for Hetzner DNS API.
Uses HetznerCloudAPIv2 (with rate limiting) for Cloud API,
HetznerLegacyAPI for deprecated dns.hetzner.com.
"""
def __init__(self, token, api_type='cloud', verbose=False):
if api_type == 'dns':
self._api = HetznerLegacyAPI(token, verbose)
else:
self._api = create_api_v2(token, 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 create_zone(self, zone_name):
return self._api.create_zone(zone_name)
def get_zone_id(self, zone_name):
return self._api.get_zone_id(zone_name)
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)
def update_ttl(self, zone_id, name, record_type, ttl):
return self._api.update_ttl(zone_id, name, record_type, ttl)
def change_ttl(self, zone_id, name, record_type, ttl):
return self._api.change_ttl(zone_id, name, record_type, ttl)
# Export all for convenience
__all__ = [
'HCloudAPI',
'HCloudAPIError',
'HetznerLegacyAPI',
'HetznerCloudAPIv2',
'HetznerAPIError',
'create_api_v2'
]

View file

@ -0,0 +1,6 @@
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Shared library for Hetzner DNS API access
"""

View file

@ -0,0 +1,380 @@
"""
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.
Contains HetznerAPIError (shared exception) and HetznerLegacyAPI (dns.hetzner.com).
For Cloud API (api.hetzner.cloud), see hetzner_api_v2.py.
"""
import syslog
import requests
TIMEOUT = 15
class HetznerAPIError(Exception):
"""Custom exception for Hetzner API errors"""
def __init__(self, message, status_code=None, response_body=None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
class 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.1',
'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 create_zone(self, zone_name):
"""Create a new DNS zone. Returns (success, message, zone_id)."""
try:
response = self._request('POST', '/zones', json_data={'name': zone_name})
if response.status_code in [200, 201]:
data = response.json()
zone = data.get('zone', {})
zone_id = zone.get('id', '')
if self.verbose:
self._log(syslog.LOG_INFO, f"Created zone {zone_name} (id={zone_id})")
return True, f"Zone {zone_name} created", zone_id
error_msg = f"HTTP {response.status_code}"
try:
error_data = response.json()
if 'error' in error_data:
error_msg = error_data['error'].get('message', error_msg)
except Exception:
pass
self._log(syslog.LOG_ERR, f"Failed to create zone {zone_name}: {error_msg}")
return False, error_msg, None
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"Failed to create zone: {str(e)}")
return False, str(e), None
def get_zone_id(self, zone_name):
"""Get zone ID by zone name"""
try:
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
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)
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:
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:
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)

View file

@ -0,0 +1,596 @@
"""
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.
Enhanced Hetzner Cloud DNS API v2 with rate limiting and retry logic.
Cloud API only (api.hetzner.cloud) - Legacy API not supported.
"""
import hashlib
import syslog
import threading
import time
import requests
from hetzner_api import HetznerAPIError
TIMEOUT = 15
ACTION_POLL_INTERVAL = 1.0 # seconds between action status polls (up from 0.5s in v1)
ACTION_MAX_WAIT = 30
MAX_RETRIES = 3
class TokenBucket:
"""
Thread-safe token bucket rate limiter.
Allows bursts up to burst_size, refills at tokens_per_second.
"""
def __init__(self, tokens_per_second=5, burst_size=10):
self.tokens_per_second = tokens_per_second
self.burst_size = burst_size
self._tokens = float(burst_size)
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def acquire(self):
"""
Acquire a token, blocking if none available.
Sleep is done outside the lock to avoid holding it during waits.
"""
while True:
with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(
self.burst_size,
self._tokens + elapsed * self.tokens_per_second
)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
# Calculate wait time for next token
wait_time = (1.0 - self._tokens) / self.tokens_per_second
time.sleep(wait_time)
class HetznerCloudAPIv2:
"""
Enhanced Hetzner Cloud DNS API with rate limiting and 429 retry.
Primary Cloud DNS API implementation with rate limiting and retry.
Cloud API only (api.hetzner.cloud).
"""
_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.1',
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
self._rate_limiter = TokenBucket(tokens_per_second=5, burst_size=10)
def _log(self, level, message):
syslog.syslog(level, f"HCloudDNS: {message}")
def _request(self, method, endpoint, params=None, json_data=None):
"""Make API request with rate limiting and 429 retry."""
url = f"{self._api_base}{endpoint}"
for attempt in range(MAX_RETRIES + 1):
# Acquire rate limit token before each request
self._rate_limiter.acquire()
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"v2: {method} {endpoint} -> {response.status_code}")
if response.status_code == 429:
if attempt < MAX_RETRIES:
# Use Retry-After header if available, else exponential backoff
retry_after = response.headers.get('Retry-After')
if retry_after:
try:
wait = float(retry_after)
except ValueError:
wait = 2 ** attempt
else:
wait = 2 ** attempt
self._log(
syslog.LOG_WARNING,
f"v2: Rate limited (429), retrying in {wait}s (attempt {attempt + 1}/{MAX_RETRIES})"
)
time.sleep(wait)
continue
else:
raise HetznerAPIError(
"Rate limited after max retries",
status_code=429
)
return response
except requests.exceptions.Timeout:
if attempt < MAX_RETRIES:
self._log(syslog.LOG_WARNING, f"v2: Timeout, retrying (attempt {attempt + 1}/{MAX_RETRIES})")
time.sleep(2 ** attempt)
continue
raise HetznerAPIError("API request timed out after retries")
except requests.exceptions.ConnectionError:
if attempt < MAX_RETRIES:
self._log(syslog.LOG_WARNING, f"v2: Connection error, retrying (attempt {attempt + 1}/{MAX_RETRIES})")
time.sleep(2 ** attempt)
continue
raise HetznerAPIError("Failed to connect to Hetzner Cloud API after retries")
except requests.exceptions.RequestException as e:
raise HetznerAPIError(f"API request failed: {str(e)}")
raise HetznerAPIError("Max retries exceeded")
def _wait_for_action(self, action_id):
"""Wait for an async action to complete with 1s polling."""
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:
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 _handle_action_response(self, response_data, context=""):
"""Check response for async action and wait for completion."""
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"Action failed{' for ' + context if context else ''}: {msg}")
return False, msg
return True, "OK"
def validate_token(self):
"""Validate token by listing zones. Returns (valid, message, zone_count)."""
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', [])
return True, f"Token valid - {len(zones)} zone(s) found", len(zones)
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 with pagination."""
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)
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"v2: Found {len(result)} zones")
return result
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"Failed to list zones: {str(e)}")
return []
def create_zone(self, zone_name):
"""Create a new DNS zone. Returns (success, message, zone_id)."""
try:
response = self._request('POST', '/zones', json_data={'name': zone_name})
if response.status_code in [200, 201]:
data = response.json()
zone = data.get('zone', {})
zone_id = zone.get('id', '')
if self.verbose:
self._log(syslog.LOG_INFO, f"v2: Created zone {zone_name} (id={zone_id})")
return True, f"Zone {zone_name} created", zone_id
error_msg = f"HTTP {response.status_code}"
try:
error_data = response.json()
if 'error' in error_data:
error_msg = error_data['error'].get('message', error_msg)
except Exception:
pass
self._log(syslog.LOG_ERR, f"Failed to create zone {zone_name}: {error_msg}")
return False, error_msg, None
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"Failed to create zone: {str(e)}")
return False, str(e), None
def get_zone_id(self, zone_name):
"""Get zone ID by zone name."""
try:
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"v2: 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 with pagination."""
if record_types is None:
record_types = ['A', 'AAAA']
try:
all_rrsets = []
page = 1
per_page = 100
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)
meta = data.get('meta', {}).get('pagination', {})
last_page = meta.get('last_page', 1)
if self.verbose:
self._log(syslog.LOG_DEBUG, f"v2: 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)
for record in records:
value = record.get('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"v2: Found {len(result)} records in zone {zone_id}")
return result
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"Failed to list records: {str(e)}")
return []
def get_record(self, zone_id, name, record_type):
"""Get a specific DNS record by name and type."""
try:
response = self._request('GET', f'/zones/{zone_id}/rrsets/{name}/{record_type}')
if response.status_code == 404:
return None
if response.status_code != 200:
self._log(syslog.LOG_ERR, f"Failed to get record: HTTP {response.status_code}")
return None
data = response.json()
rrset = data.get('rrset', {})
records = rrset.get('records', [])
value = records[0].get('value', '') if records else ''
return {
'name': rrset.get('name', ''),
'type': rrset.get('type', ''),
'value': value,
'ttl': rrset.get('ttl', 300)
}
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"Failed to get record: {str(e)}")
return None
def _parse_error(self, response):
"""Extract error message from API 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
return error_msg
def change_ttl(self, zone_id, name, record_type, ttl):
"""
Change TTL of an RRset using the dedicated change_ttl action endpoint.
Returns tuple (success: bool, message: str)
"""
try:
url = f'/zones/{zone_id}/rrsets/{name}/{record_type}/actions/change_ttl'
response = self._request('POST', url, json_data={'ttl': ttl})
if response.status_code in [200, 201]:
success, msg = self._handle_action_response(
response.json(), f"{name} {record_type} TTL"
)
if not success:
return False, msg
if self.verbose:
self._log(syslog.LOG_INFO, f"v2: Changed TTL for {name} {record_type} -> {ttl}")
return True, f"TTL updated to {ttl}"
error_msg = self._parse_error(response)
self._log(syslog.LOG_ERR, f"v2: Failed to change TTL for {name} {record_type}: {error_msg}")
return False, error_msg
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"v2: Failed to change TTL: {str(e)}")
return False, str(e)
def update_record(self, zone_id, name, record_type, value, ttl=300):
"""
Update existing record value and/or TTL.
Returns tuple (success: bool, message: str)
Uses set_records for value changes and change_ttl for TTL changes,
as per Hetzner Cloud API requirements (separate endpoints).
"""
try:
existing = self.get_record(zone_id, name, record_type)
if not existing:
return self.create_record(zone_id, name, record_type, value, ttl)
value_changed = existing.get('value') != str(value)
ttl_changed = existing.get('ttl') != ttl
if not value_changed and not ttl_changed:
return True, "unchanged"
# Update value via set_records if changed
if value_changed:
url = f'/zones/{zone_id}/rrsets/{name}/{record_type}/actions/set_records'
response = self._request('POST', url, json_data={'records': [{'value': str(value)}]})
if response.status_code not in [200, 201]:
error_msg = self._parse_error(response)
self._log(syslog.LOG_ERR, f"v2: Failed to update {name} {record_type}: {error_msg}")
return False, error_msg
success, msg = self._handle_action_response(
response.json(), f"{name} {record_type}"
)
if not success:
return False, msg
if self.verbose:
self._log(syslog.LOG_INFO, f"v2: Updated {name} {record_type} -> {value}")
# Update TTL via change_ttl if changed
if ttl_changed:
success, msg = self.change_ttl(zone_id, name, record_type, ttl)
if not success:
return False, msg
return True, f"Updated {name} {record_type}"
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"v2: 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 (success, message)."""
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]:
try:
response_data = response.json()
success, msg = self._handle_action_response(
response_data, f"{name} {record_type}"
)
if not success:
return False, msg
except Exception:
pass
if self.verbose:
self._log(syslog.LOG_INFO, f"v2: 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 (success, message)."""
try:
response = self._request('DELETE', f'/zones/{zone_id}/rrsets/{name}/{record_type}')
if response.status_code in [200, 201, 204]:
try:
response_data = response.json()
success, msg = self._handle_action_response(
response_data, f"{name} {record_type}"
)
if not success:
return False, msg
except Exception:
pass
if self.verbose:
self._log(syslog.LOG_INFO, f"v2: 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)
def update_ttl(self, zone_id, name, record_type, ttl):
"""Update only the TTL of an existing record using the dedicated change_ttl endpoint."""
try:
existing = self.get_record(zone_id, name, record_type)
if not existing:
return False, "Record not found"
if existing.get('ttl') == ttl:
return True, "unchanged"
return self.change_ttl(zone_id, name, record_type, ttl)
except HetznerAPIError as e:
self._log(syslog.LOG_ERR, f"v2: Failed to update TTL: {str(e)}")
return False, str(e)
def create_api_v2(token, verbose=False):
"""Factory function to create a v2 API instance."""
return HetznerCloudAPIv2(token, verbose)

View file

@ -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 <token> <zone_id> [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()

View file

@ -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()

View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Manage DNS change history (cleanup, clear, revert, get) for HCloudDNS
"""
import fcntl
import json
import os
import sys
import time
HISTORY_FILE = '/var/log/hclouddns/history.jsonl'
def _read_entries():
"""Read all JSONL entries"""
entries = []
if not os.path.exists(HISTORY_FILE):
return entries
with open(HISTORY_FILE, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def _write_entries(entries):
"""Write entries back to JSONL with locking"""
fd = os.open(HISTORY_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, 'w') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
for entry in entries:
f.write(json.dumps(entry) + '\n')
finally:
fcntl.flock(f, fcntl.LOCK_UN)
except Exception:
try:
os.close(fd)
except OSError:
pass
raise
def cleanup(days):
"""Remove entries older than N days"""
days = int(days)
if days <= 0:
return {'status': 'ok', 'deleted': 0, 'message': 'No cleanup needed'}
cutoff = int(time.time()) - (days * 86400)
entries = _read_entries()
kept = [e for e in entries if e.get('timestamp', 0) >= cutoff]
deleted = len(entries) - len(kept)
if deleted > 0:
_write_entries(kept)
return {
'status': 'ok',
'deleted': deleted,
'message': f'Cleaned up {deleted} old history entries'
}
def clear():
"""Remove all history entries"""
entries = _read_entries()
deleted = len(entries)
if deleted > 0:
_write_entries([])
return {
'status': 'ok',
'deleted': deleted,
'message': f'Cleared all {deleted} history entries'
}
def revert(uuid):
"""Mark an entry as reverted (actual DNS revert is done by PHP controller)"""
entries = _read_entries()
for entry in entries:
if entry.get('uuid') == uuid:
if entry.get('reverted'):
return {'status': 'error', 'message': 'Already reverted'}
entry['reverted'] = True
_write_entries(entries)
return {'status': 'ok', 'message': 'Entry marked as reverted'}
return {'status': 'error', 'message': 'Entry not found'}
def get(uuid):
"""Get a single history entry by UUID"""
entries = _read_entries()
for entry in entries:
if entry.get('uuid') == uuid:
ts = entry.get('timestamp', 0)
entry['timestampFormatted'] = time.strftime(
'%Y-%m-%d %H:%M:%S', time.localtime(ts)
)
entry['reverted'] = '1' if entry.get('reverted') else '0'
return {'status': 'ok', 'change': entry}
return {'status': 'error', 'message': 'Entry not found'}
def main():
if len(sys.argv) < 2:
print(json.dumps({'status': 'error', 'message': 'Usage: manage_history.py <action> [args]'}))
sys.exit(1)
action = sys.argv[1]
if action == 'cleanup':
days = sys.argv[2] if len(sys.argv) > 2 else '7'
result = cleanup(days)
elif action == 'clear':
result = clear()
elif action == 'revert':
if len(sys.argv) < 3:
result = {'status': 'error', 'message': 'UUID required'}
else:
result = revert(sys.argv[2])
elif action == 'get':
if len(sys.argv) < 3:
result = {'status': 'error', 'message': 'UUID required'}
else:
result = get(sys.argv[2])
else:
result = {'status': 'error', 'message': f'Unknown action: {action}'}
print(json.dumps(result))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Read DNS change history from JSONL file for HCloudDNS
"""
import json
import os
import sys
import time
HISTORY_FILE = '/var/log/hclouddns/history.jsonl'
def read_history():
"""Read all history entries from JSONL file, return newest-first"""
rows = []
if not os.path.exists(HISTORY_FILE):
return {'status': 'ok', 'rows': [], 'rowCount': 0, 'total': 0, 'current': 1}
try:
with open(HISTORY_FILE, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
# Add formatted timestamp
ts = entry.get('timestamp', 0)
entry['timestampFormatted'] = time.strftime(
'%Y-%m-%d %H:%M:%S', time.localtime(ts)
)
# Normalize reverted to string for PHP compatibility
entry['reverted'] = '1' if entry.get('reverted') else '0'
rows.append(entry)
except json.JSONDecodeError:
continue
except IOError:
pass
# Sort newest first
rows.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
return {
'status': 'ok',
'rows': rows,
'rowCount': len(rows),
'total': len(rows),
'current': 1
}
def read_stats(days=30):
"""Aggregate statistics from history entries."""
rows = []
if not os.path.exists(HISTORY_FILE):
return {
'status': 'ok',
'total': 0, 'creates': 0, 'updates': 0, 'deletes': 0,
'reverted': 0,
'byDate': {}, 'byZone': {}, 'byAccount': {},
'avgPerDay': 0
}
cutoff = time.time() - (days * 86400)
try:
with open(HISTORY_FILE, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
rows.append(entry)
except json.JSONDecodeError:
continue
except IOError:
pass
# Filter by time range
filtered = [r for r in rows if r.get('timestamp', 0) >= cutoff]
total = len(filtered)
creates = sum(1 for r in filtered if r.get('action') == 'create')
updates = sum(1 for r in filtered if r.get('action') == 'update')
deletes = sum(1 for r in filtered if r.get('action') == 'delete')
reverted = sum(1 for r in filtered if r.get('reverted'))
# Group by date
by_date = {}
for r in filtered:
date_str = time.strftime('%Y-%m-%d', time.localtime(r.get('timestamp', 0)))
if date_str not in by_date:
by_date[date_str] = {'create': 0, 'update': 0, 'delete': 0}
action = r.get('action', '')
if action in by_date[date_str]:
by_date[date_str][action] += 1
# Group by zone
by_zone = {}
for r in filtered:
zone = r.get('zoneName', 'Unknown')
by_zone[zone] = by_zone.get(zone, 0) + 1
# Group by account
by_account = {}
for r in filtered:
account = r.get('accountName', 'Unknown')
by_account[account] = by_account.get(account, 0) + 1
# Avg per day
unique_days = len(by_date) if by_date else 1
avg_per_day = round(total / unique_days, 1)
return {
'status': 'ok',
'total': total,
'creates': creates,
'updates': updates,
'deletes': deletes,
'reverted': reverted,
'byDate': by_date,
'byZone': by_zone,
'byAccount': by_account,
'avgPerDay': avg_per_day
}
def main():
# Check for stats mode
if len(sys.argv) > 1 and sys.argv[1] == 'stats':
days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
result = read_stats(days)
else:
result = read_history()
print(json.dumps(result))
if __name__ == '__main__':
main()

View file

@ -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()

View file

@ -0,0 +1,44 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Service control for HCloudDNS - handles start/stop via configd
"""
import sys
import json
import os
import syslog
STOPPED_FLAG = '/var/run/hclouddns.stopped'
def log(message, priority=syslog.LOG_INFO):
syslog.openlog('hclouddns', syslog.LOG_PID, syslog.LOG_LOCAL4)
syslog.syslog(priority, message)
def main():
action = sys.argv[1] if len(sys.argv) > 1 else 'help'
if action == 'stop':
log('Service stop requested')
try:
fd = os.open(STOPPED_FLAG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.close(fd)
except OSError:
pass
print(json.dumps({'status': 'ok', 'message': 'HCloudDNS service stopped'}))
elif action == 'start':
log('Service start requested')
try:
os.unlink(STOPPED_FLAG)
except FileNotFoundError:
pass
print(json.dumps({'status': 'ok', 'message': 'HCloudDNS service started'}))
else:
print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'}))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,150 @@
#!/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
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gateway_health import write_state_file
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:
write_state_file(SIMULATION_FILE, sim)
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:
write_state_file(STATE_FILE, state)
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 <action> [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()

View file

@ -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()

View file

@ -0,0 +1,231 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Test notification channels for HCloudDNS
"""
import json
import sys
import smtplib
import urllib.request
import urllib.error
from email.mime.text import MIMEText
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', ''),
'emailFrom': notifications.findtext('emailFrom', ''),
'smtpServer': notifications.findtext('smtpServer', ''),
'smtpPort': int(notifications.findtext('smtpPort', '587')),
'smtpTls': notifications.findtext('smtpTls', 'starttls'),
'smtpUser': notifications.findtext('smtpUser', ''),
'smtpPassword': notifications.findtext('smtpPassword', ''),
'webhookEnabled': notifications.findtext('webhookEnabled', '0') == '1',
'webhookUrl': notifications.findtext('webhookUrl', ''),
'webhookMethod': notifications.findtext('webhookMethod', 'POST'),
'webhookSecret': notifications.findtext('webhookSecret', ''),
'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(settings):
"""Send test email via SMTP"""
try:
to_addr = settings.get('emailTo', '')
from_addr = settings.get('emailFrom', '') or f"hclouddns@{settings.get('smtpServer', 'localhost')}"
server = settings.get('smtpServer', '')
port = settings.get('smtpPort', 587)
tls_mode = settings.get('smtpTls', 'starttls')
user = settings.get('smtpUser', '')
password = settings.get('smtpPassword', '')
if not server:
return {'success': False, 'message': 'SMTP server not configured'}
if not to_addr:
return {'success': False, 'message': 'Recipient address not configured'}
msg = MIMEText("This is a test notification from HCloudDNS plugin.\n\nIf you received this, email notifications are working correctly.")
msg['Subject'] = 'HCloudDNS Test Notification'
msg['From'] = from_addr
msg['To'] = to_addr
if tls_mode == 'ssl':
smtp = smtplib.SMTP_SSL(server, port, timeout=15)
else:
smtp = smtplib.SMTP(server, port, timeout=15)
try:
if tls_mode == 'starttls':
smtp.starttls()
if user and password:
smtp.login(user, password)
smtp.sendmail(from_addr, [to_addr], msg.as_string())
return {'success': True, 'message': f'Sent to {to_addr}'}
finally:
smtp.quit()
except smtplib.SMTPAuthenticationError as e:
return {'success': False, 'message': f'Auth failed: {str(e)[:80]}'}
except smtplib.SMTPException as e:
return {'success': False, 'message': f'SMTP error: {str(e)[:80]}'}
except Exception as e:
return {'success': False, 'message': str(e)[:100]}
def send_webhook(url, method, secret=''):
"""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 secret:
import hmac
import hashlib
import time as time_mod
timestamp = str(int(time_mod.time()))
sig = hmac.new(
secret.encode(),
timestamp.encode() + b'.' + data,
hashlib.sha256
).hexdigest()
headers['X-HCloudDNS-Signature'] = sig
headers['X-HCloudDNS-Timestamp'] = timestamp
if method == 'GET':
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():
# Optional channel filter: email, webhook, ntfy
channel_filter = sys.argv[1].strip() if len(sys.argv) > 1 and sys.argv[1].strip() else None
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
channels_tested = 0
if channel_filter is None or channel_filter == 'email':
if settings['emailEnabled']:
result['results']['email'] = send_email(settings)
channels_tested += 1
if channel_filter is None or channel_filter == 'webhook':
if settings['webhookEnabled'] and settings['webhookUrl']:
result['results']['webhook'] = send_webhook(settings['webhookUrl'], settings['webhookMethod'], settings.get('webhookSecret', ''))
channels_tested += 1
if channel_filter is None or channel_filter == 'ntfy':
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'
if channel_filter:
result['message'] = f'{channel_filter} channel is not enabled'
else:
result['message'] = 'No notification channels configured'
else:
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()

View file

@ -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 <token> <zone_id> <name> <type> <value> <ttl>'
}))
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()

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -0,0 +1,104 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Export DNS zone in BIND format for HCloudDNS.
"""
import json
import sys
import os
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from hcloud_api import HCloudAPI
ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'SOA']
def export_zone(token, zone_id):
"""Export zone as BIND-format zonefile."""
api = HCloudAPI(token)
zones = api.list_zones()
zone_name = zone_id
for z in zones:
if z.get('id') == zone_id:
zone_name = z.get('name', zone_id)
break
records = api.list_records(zone_id, ALL_RECORD_TYPES)
lines = []
lines.append(f'; Zone: {zone_name}')
lines.append(f'; Exported: {time.strftime("%Y-%m-%d %H:%M:%S")}')
lines.append(f'; Records: {len(records)}')
lines.append(f'$ORIGIN {zone_name}.')
lines.append('')
# Sort records: SOA first, then NS, then by type and name
type_order = {'SOA': 0, 'NS': 1, 'A': 2, 'AAAA': 3, 'CNAME': 4, 'MX': 5, 'TXT': 6, 'SRV': 7, 'CAA': 8}
records.sort(key=lambda r: (
type_order.get(r.get('type', ''), 99),
r.get('name', '')
))
for rec in records:
name = rec.get('name', '@')
rtype = rec.get('type', 'A')
value = rec.get('value', '')
ttl = rec.get('ttl', 300)
# Format name: pad to 16 chars
display_name = name if name != '@' else '@'
display_name = display_name.ljust(16)
# Format value based on type
if rtype == 'TXT':
# Ensure TXT values are quoted
if not value.startswith('"'):
value = f'"{value}"'
elif rtype == 'CNAME' or rtype == 'NS' or rtype == 'MX':
# Add trailing dot if not present
if value and not value.endswith('.') and not value.endswith('. '):
# For MX: priority hostname.
parts = value.split()
if rtype == 'MX' and len(parts) == 2:
if not parts[1].endswith('.'):
value = f'{parts[0]} {parts[1]}.'
elif rtype != 'MX' and not value.endswith('.'):
value = value + '.'
lines.append(f'{display_name} {ttl}\tIN\t{rtype}\t{value}')
content = '\n'.join(lines) + '\n'
filename = f'{zone_name}.zone'
return {
'status': 'ok',
'content': content,
'filename': filename,
'zone': zone_name,
'recordCount': len(records)
}
def main():
if len(sys.argv) < 3:
print(json.dumps({
'status': 'error',
'message': 'Usage: zone_export.py <token> <zone_id>'
}))
sys.exit(1)
token = sys.argv[1].strip()
zone_id = sys.argv[2].strip()
result = export_zone(token, zone_id)
print(json.dumps(result))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,188 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
All rights reserved.
Parse BIND zonefile for importing into HCloudDNS.
Only parses - does not create records. Frontend handles selection and creation.
"""
import json
import re
import sys
def parse_zonefile(content):
"""Parse BIND-format zonefile content into records."""
records = []
origin = ''
default_ttl = 300
for line in content.split('\n'):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(';'):
continue
# Handle $ORIGIN
if line.upper().startswith('$ORIGIN'):
origin = line.split(None, 1)[1].rstrip('.') if len(line.split(None, 1)) > 1 else ''
continue
# Handle $TTL
if line.upper().startswith('$TTL'):
try:
default_ttl = int(line.split(None, 1)[1])
except (ValueError, IndexError):
pass
continue
# Skip other directives
if line.startswith('$'):
continue
# Parse record line
record = parse_record_line(line, default_ttl)
if record:
records.append(record)
return records
def parse_record_line(line, default_ttl):
"""Parse a single DNS record line."""
# Remove inline comments
if ';' in line and '"' not in line.split(';')[0]:
line = line.split(';')[0].strip()
elif ';' in line:
# Handle TXT records with semicolons inside quotes
in_quotes = False
clean = []
for char in line:
if char == '"':
in_quotes = not in_quotes
if char == ';' and not in_quotes:
break
clean.append(char)
line = ''.join(clean).strip()
if not line:
return None
# Tokenize respecting quoted strings
tokens = tokenize(line)
if len(tokens) < 3:
return None
name = ''
ttl = default_ttl
rclass = 'IN'
rtype = ''
value = ''
idx = 0
# First token: name or empty (continuation)
if tokens[0] not in ('IN', 'CH', 'HS') and not is_record_type(tokens[0]) and not tokens[0].isdigit():
name = tokens[0]
idx = 1
else:
name = '@'
# Next: optional TTL
if idx < len(tokens) and tokens[idx].isdigit():
ttl = int(tokens[idx])
idx += 1
# Next: optional class
if idx < len(tokens) and tokens[idx].upper() in ('IN', 'CH', 'HS'):
rclass = tokens[idx].upper()
idx += 1
# Next: record type
if idx < len(tokens) and is_record_type(tokens[idx]):
rtype = tokens[idx].upper()
idx += 1
else:
return None
# Rest is the value
value = ' '.join(tokens[idx:])
# Clean up TXT values
if rtype == 'TXT' and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
# Clean trailing dots from hostnames
if rtype in ('CNAME', 'NS') and value.endswith('.'):
value = value[:-1]
# Clean name
if name.endswith('.'):
name = name[:-1]
if not rtype or not value:
return None
return {
'name': name,
'type': rtype,
'value': value,
'ttl': ttl
}
def tokenize(line):
"""Split line into tokens, respecting quoted strings."""
tokens = []
current = []
in_quotes = False
for char in line:
if char == '"':
in_quotes = not in_quotes
current.append(char)
elif char in (' ', '\t') and not in_quotes:
if current:
tokens.append(''.join(current))
current = []
else:
current.append(char)
if current:
tokens.append(''.join(current))
return tokens
def is_record_type(token):
"""Check if token is a known DNS record type."""
return token.upper() in (
'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA',
'SOA', 'PTR', 'TLSA', 'DNSKEY', 'DS', 'NAPTR', 'SSHFP'
)
def main():
# Read zonefile content from stdin
content = sys.stdin.read()
if not content.strip():
print(json.dumps({
'status': 'error',
'message': 'No zonefile content provided'
}))
sys.exit(1)
records = parse_zonefile(content)
print(json.dumps({
'status': 'ok',
'records': records,
'count': len(records)
}))
if __name__ == '__main__':
main()

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,227 @@
[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
[start]
command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py
parameters:
type:script_output
message:Starting HCloudDNS service
[stop]
command:/usr/local/opnsense/scripts/HCloudDNS/service_control.py stop
parameters:
type:script_output
message:Stopping HCloudDNS service
[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
[dryrun]
command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --dry-run
parameters:
type:script_output
message:Preview DNS changes (dry run)
[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.createzone]
command:/usr/local/opnsense/scripts/HCloudDNS/create_zone.py
parameters:%s %s
type:script_output
message:Creating DNS zone at Hetzner
[dns.create]
command:/usr/local/opnsense/scripts/HCloudDNS/create_record.py
parameters:%s %s %s %s %s %s
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:%s
type:script_output
message:Testing notification channels
[maintenance.start]
command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-start
parameters:%s
type:script_output
message:Starting gateway maintenance mode
[maintenance.stop]
command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-stop
parameters:%s
type:script_output
message:Stopping gateway maintenance mode
[maintenance.schedule]
command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-schedule
parameters:%s %s %s
type:script_output
message:Scheduling gateway maintenance window
[propagation.check]
command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py propagation
parameters:%s %s %s %s
type:script_output
message:Checking DNS propagation
[dns.export]
command:/usr/local/opnsense/scripts/HCloudDNS/zone_export.py
parameters:%s %s
type:script_output
message:Export DNS zone
[dns.parseimport]
command:/usr/local/opnsense/scripts/HCloudDNS/zone_import.py
parameters:
type:script_output
message:Parse zone import
[dns.healthcheck]
command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py health
parameters:%s %s %s
type:script_output
message:DNS health check
[dns.propagation]
command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py propagation
parameters:%s %s
type:script_output
message:DNS propagation check
[dns.dnssec]
command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py dnssec
parameters:%s %s
type:script_output
message:Checking DNSSEC status
[history.search]
command:/usr/local/opnsense/scripts/HCloudDNS/read_history.py
parameters:
type:script_output
message:Reading DNS change history
[history.stats]
command:/usr/local/opnsense/scripts/HCloudDNS/read_history.py stats
parameters:%s
type:script_output
message:Getting history statistics
[history.cleanup]
command:/usr/local/opnsense/scripts/HCloudDNS/manage_history.py cleanup
parameters:%s
type:script_output
message:Cleaning up old history entries
[history.clear]
command:/usr/local/opnsense/scripts/HCloudDNS/manage_history.py clear
parameters:
type:script_output
message:Clearing all history entries
[history.revert]
command:/usr/local/opnsense/scripts/HCloudDNS/manage_history.py revert
parameters:%s
type:script_output
message:Reverting history entry
[history.get]
command:/usr/local/opnsense/scripts/HCloudDNS/manage_history.py get
parameters:%s
type:script_output
message:Getting history entry

View file

@ -0,0 +1,6 @@
###################################################################
# Local syslog-ng configuration filter definition [hclouddns].
###################################################################
filter f_local_hclouddns {
program("hclouddns") or message("HCloudDNS:");
};