mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
Merge bddbd9a1c1 into cb9a5d6d69
This commit is contained in:
commit
929bd43b1b
73 changed files with 21252 additions and 20 deletions
|
|
@ -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
8
net/hclouddns/Makefile
Normal 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
168
net/hclouddns/deploy.sh
Executable 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
51
net/hclouddns/install.sh
Executable 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
16
net/hclouddns/pkg-descr
Normal 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
53
net/hclouddns/pkg-plist
Normal 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
|
||||
138
net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc
Normal file
138
net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc
Normal 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);
|
||||
}
|
||||
51
net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns
Executable file
51
net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns
Executable 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
|
||||
42
net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns
Normal file
42
net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns
Normal 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
|
||||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] ?? []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')]) }}
|
||||
3848
net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt
Normal file
3848
net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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')]) }}
|
||||
|
|
@ -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')]) }}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>×</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> </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>
|
||||
|
|
@ -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, '"');
|
||||
|
||||
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(/"/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> </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>
|
||||
|
|
@ -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()
|
||||
70
net/hclouddns/src/opnsense/scripts/HCloudDNS/create_zone.py
Normal file
70
net/hclouddns/src/opnsense/scripts/HCloudDNS/create_zone.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
488
net/hclouddns/src/opnsense/scripts/HCloudDNS/dns_health_check.py
Executable file
488
net/hclouddns/src/opnsense/scripts/HCloudDNS/dns_health_check.py
Executable 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()
|
||||
498
net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py
Executable file
498
net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py
Executable 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()
|
||||
64
net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py
Executable file
64
net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py
Executable 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()
|
||||
106
net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py
Executable file
106
net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py
Executable 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'
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
|
||||
All rights reserved.
|
||||
|
||||
Shared library for Hetzner DNS API access
|
||||
"""
|
||||
380
net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py
Normal file
380
net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
62
net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py
Executable file
62
net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py
Executable 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()
|
||||
49
net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py
Executable file
49
net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py
Executable 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()
|
||||
150
net/hclouddns/src/opnsense/scripts/HCloudDNS/manage_history.py
Executable file
150
net/hclouddns/src/opnsense/scripts/HCloudDNS/manage_history.py
Executable 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()
|
||||
146
net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py
Executable file
146
net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py
Executable 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()
|
||||
136
net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py
Executable file
136
net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py
Executable 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()
|
||||
44
net/hclouddns/src/opnsense/scripts/HCloudDNS/service_control.py
Executable file
44
net/hclouddns/src/opnsense/scripts/HCloudDNS/service_control.py
Executable 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()
|
||||
150
net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py
Executable file
150
net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py
Executable 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()
|
||||
124
net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py
Executable file
124
net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py
Executable 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()
|
||||
231
net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py
Normal file
231
net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
1785
net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py
Executable file
1785
net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py
Executable file
File diff suppressed because it is too large
Load diff
52
net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py
Executable file
52
net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py
Executable 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()
|
||||
104
net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_export.py
Executable file
104
net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_export.py
Executable 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()
|
||||
188
net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_import.py
Executable file
188
net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_import.py
Executable 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
###################################################################
|
||||
# Local syslog-ng configuration filter definition [hclouddns].
|
||||
###################################################################
|
||||
filter f_local_hclouddns {
|
||||
program("hclouddns") or message("HCloudDNS:");
|
||||
};
|
||||
Loading…
Reference in a new issue