From a4e0b1adc527f7329b14b2e4e0fea10f8c04128a Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 00:23:42 +0200 Subject: [PATCH 1/9] One seconddns command, on every panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public guides tell customers to run `seconddns sync`, `seconddns list`, `seconddns status ` and `seconddns remove-all`. Only CyberPanel had a `seconddns` command at all, with only add/remove/sync/list; on DirectAdmin, cPanel and Plesk every one of those is a command not found, and `status ` and `remove-all` existed nowhere. The shared CLI now ships with the other tools on all four panels. Every change goes through seconddns-queue, so it carries retry, FIFO and the delete-side ownership check, the same as the hooks. Deleting is never implicit, which is where CyberPanel's version was dangerous: its `sync` removed every remote zone missing from the local list, with no guard at all — a panel that failed to answer, which we have seen happen, would have wiped a customer's zones. Here plain `sync` only adds; `--prune` removes and is refused when it would drop more than half of this server's zones; `remove-all` prints what it would remove and asks for the master IP back. CyberPanel keeps seconddns.py as the Django plugin only. Its signals service now runs the plugin directly for ensure-signals, since that command is not part of the shared CLI, and the plugin is installed executable for it. tests/test_cli.sh: 20 checks, including both sides of the prune guard (half of two allowed, two of three refused), a zone mastered elsewhere never touched, remove-all refusing without confirmation, and list/status queuing nothing. --- .github/workflows/tests.yml | 3 + hosting-panels/common/seconddns | 203 ++++++++++++++++++ hosting-panels/common/tests/test_cli.sh | 107 +++++++++ hosting-panels/cpanel/install.sh | 3 +- hosting-panels/cpanel/uninstall.sh | 2 +- hosting-panels/cyberpanel/install.sh | 13 +- .../cyberpanel/seconddns-signals.service | 2 +- hosting-panels/cyberpanel/seconddns.py | 49 +---- hosting-panels/cyberpanel/uninstall.sh | 2 +- hosting-panels/directadmin/install.sh | 3 +- hosting-panels/directadmin/uninstall.sh | 2 +- hosting-panels/plesk/install.sh | 3 +- hosting-panels/plesk/uninstall.sh | 2 +- 13 files changed, 333 insertions(+), 61 deletions(-) create mode 100755 hosting-panels/common/seconddns create mode 100755 hosting-panels/common/tests/test_cli.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b84c65b..64c8eed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,9 @@ jobs: - name: test_ip_detection.sh run: bash hosting-panels/common/tests/test_ip_detection.sh + - name: test_cli.sh + run: bash hosting-panels/common/tests/test_cli.sh + - name: test_cyberpanel_db.sh run: bash hosting-panels/common/tests/test_cyberpanel_db.sh diff --git a/hosting-panels/common/seconddns b/hosting-panels/common/seconddns new file mode 100755 index 0000000..837aa61 --- /dev/null +++ b/hosting-panels/common/seconddns @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# Copyright © 2025-2026 SecondDNS +# Licensed under GNU General Public License v3.0 or SecondDNS Commercial License +# See LICENSE (GPLv3) or LICENSE.COMMERCIAL (commercial) for details +"""Manage this server's zones in SecondDNS. + + seconddns list zones in SecondDNS, with status and master + seconddns status queue state: pending / failed / oldest / last error + seconddns status one zone: known here, mastered by whom, last sync + seconddns add register a zone + seconddns remove remove a zone + seconddns sync register every panel zone SecondDNS does not have + seconddns sync --prune also remove zones this panel no longer serves + seconddns remove-all remove every zone mastered by this server + +Changes go through seconddns-queue, so they carry retry, FIFO order and the +delete-side ownership check. Deleting is never implicit: plain `sync` only +adds, `--prune` is refused when it would drop more than half of this server's +zones, and `remove-all` asks before doing anything. + +Exit 0 = done, 1 = something failed, 2 = refused. +""" +import argparse +import os +import signal +import subprocess +import sys + +# piping into head/grep must not end in a BrokenPipeError traceback +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from seconddns_common import ( # noqa: E402 + api, load_config, panel_zones, canonical, canonical_targets, enqueue, PanelError, QUEUE_BIN, +) + +UA = "SecondDNS-CLI/1.0" + + +def fetch_zones(cfg): + code, zones = api(cfg, "GET", "/api/zones", ua=UA) + if code != 200 or not isinstance(zones, list): + why = zones.get("error", "") if isinstance(zones, dict) else "" + sys.exit(f"GET /api/zones failed: HTTP {code} {why}") + return zones + + +def resolve(name): + """Panel input to the canonical zone name, or exit with the reason.""" + canonical_name, reason = canonical(name) + if reason: + sys.exit(f"{name}: {reason}") + return canonical_name + + +def cmd_list(cfg, _a): + zones = fetch_zones(cfg) + if not zones: + print("No zones in SecondDNS for this account.") + return 0 + print(f"{'Name':<40} {'Status':<10} {'Master':<18} Last sync") + print("-" * 90) + for z in sorted(zones, key=lambda z: z["name"]): + print(f"{z['name']:<40} {z.get('status', '?'):<10} {z.get('masterIp', '?'):<18} {z.get('lastSync') or 'never'}") + return 0 + + +def cmd_status(cfg, a): + if a.domain: + name = resolve(a.domain) + zone = next((z for z in fetch_zones(cfg) if z["name"] == name), None) + if zone is None: + print(f"{name}: not in SecondDNS") + return 1 + mine = zone.get("masterIp") == cfg["master_ip"] + print(f"{name}: {zone.get('status', '?')}, mastered by {zone.get('masterIp', '?')}" + f"{'' if mine else ' (not this server)'}, last sync {zone.get('lastSync') or 'never'}") + return 0 + # no domain: the queue is what an operator is asking about + return subprocess.run([QUEUE_BIN, "status"]).returncode + + +def cmd_add(cfg, a): + name = resolve(a.domain) + if enqueue("create", name, cfg["master_ip"]): + print(f"queued: create {name}") + return 0 + print(f"could not queue: create {name}", file=sys.stderr) + return 1 + + +def cmd_remove(cfg, a): + name = resolve(a.domain) + if enqueue("delete", name, cfg["master_ip"]): + print(f"queued: delete {name}") + return 0 + print(f"could not queue: delete {name}", file=sys.stderr) + return 1 + + +def cmd_sync(cfg, a): + try: + raw = panel_zones() + except PanelError as e: + sys.exit(str(e)) + panel, skipped = canonical_targets(raw) + if raw and not panel: + sys.exit("the panel's zone names are all unusable; refusing to continue") + for name, why in skipped: + print(f"[!] skipped {name}: {why}", file=sys.stderr) + + zones = fetch_zones(cfg) + have = {z["name"] for z in zones} + mine = [z for z in zones if z.get("masterIp") == cfg["master_ip"]] + + missing = [n for n in panel if n not in have] + failed = 0 + for n in missing: + if enqueue("create", n, cfg["master_ip"]): + print(f"queued: create {n}") + else: + print(f"could not queue: create {n}", file=sys.stderr); failed += 1 + + pruned = [] + if a.prune: + panel_set = set(panel) + stale = [z for z in mine if z["name"] not in panel_set] + # an unreadable or half-read panel looks exactly like "everything is gone" + if stale and len(stale) * 2 > len(mine): + msg = (f"--prune would remove {len(stale)} of {len(mine)} zone(s) mastered by this server; " + "that looks like a wrong zone list, not a cleanup") + if not a.force: + print(msg + ". Check `seconddns list`; --force overrides.", file=sys.stderr) + return 2 + print("[!] " + msg + " — proceeding on --force", file=sys.stderr) + for z in stale: + if enqueue("delete", z["name"], cfg["master_ip"]): + print(f"queued: delete {z['name']}"); pruned.append(z["name"]) + else: + print(f"could not queue: delete {z['name']}", file=sys.stderr); failed += 1 + + print(f"\n{len(missing)} to add, {len(pruned)} to remove, {failed} failed") + return 1 if failed else 0 + + +def cmd_remove_all(cfg, a): + mine = [z for z in fetch_zones(cfg) if z.get("masterIp") == cfg["master_ip"]] + if not mine: + print("No zones mastered by this server.") + return 0 + print(f"This removes {len(mine)} zone(s) mastered by {cfg['master_ip']} from SecondDNS:") + for z in sorted(mine, key=lambda z: z["name"])[:10]: + print(f" {z['name']}") + if len(mine) > 10: + print(f" ... and {len(mine) - 10} more") + print("Your DNS server keeps them; only the secondary copies go.") + if not a.yes: + if not sys.stdin.isatty(): + print("Refusing without a terminal to confirm on; pass --yes if you mean it.", file=sys.stderr) + return 2 + if input("Type the master IP to confirm: ").strip() != cfg["master_ip"]: + print("Not confirmed; nothing done.", file=sys.stderr) + return 2 + failed = 0 + for z in mine: + if enqueue("delete", z["name"], cfg["master_ip"]): + print(f"queued: delete {z['name']}") + else: + print(f"could not queue: delete {z['name']}", file=sys.stderr); failed += 1 + return 1 if failed else 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.environ.get("SECONDDNS_CONF", "/etc/seconddns.conf")) + sub = ap.add_subparsers(dest="cmd") + + sub.add_parser("list") + p = sub.add_parser("status"); p.add_argument("domain", nargs="?") + p = sub.add_parser("add"); p.add_argument("domain") + p = sub.add_parser("remove"); p.add_argument("domain") + p = sub.add_parser("sync") + p.add_argument("--prune", action="store_true", help="also remove zones this panel no longer serves") + p.add_argument("--force", action="store_true", help="allow --prune to drop more than half of this server's zones") + p = sub.add_parser("remove-all"); p.add_argument("--yes", action="store_true", help="skip the confirmation") + + a = ap.parse_args() + if not a.cmd: + ap.print_help() + return 2 + + cfg = load_config(a.config) + if not cfg["master_ip"]: + sys.exit("master_ip missing in the config") + + return { + "list": cmd_list, "status": cmd_status, "add": cmd_add, "remove": cmd_remove, + "sync": cmd_sync, "remove-all": cmd_remove_all, + }[a.cmd](cfg, a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hosting-panels/common/tests/test_cli.sh b/hosting-panels/common/tests/test_cli.sh new file mode 100755 index 0000000..36c1d9c --- /dev/null +++ b/hosting-panels/common/tests/test_cli.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# The seconddns CLI: deleting is never implicit, and every change goes through +# the queue rather than straight to the API. +# Run: bash hosting-panels/common/tests/test_cli.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +CLI="$HERE/../seconddns" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +command -v idn2 >/dev/null || { echo "idn2 required"; exit 1; } + +TMP="$(mktemp -d)"; PORT=$((20000 + RANDOM % 10000)) +trap 'kill $MOCK_PID 2>/dev/null; rm -rf "$TMP"' EXIT + +python3 - "$PORT" "$TMP" > /dev/null 2>&1 <<'PYEOF' & +import http.server, sys +port, tmp = int(sys.argv[1]), sys.argv[2] +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + b = open(tmp + '/zones.json', 'rb').read() + self.send_response(200); self.send_header('Content-Length', str(len(b))); self.end_headers() + self.wfile.write(b) + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', port), H).serve_forever() +PYEOF +MOCK_PID=$! +disown $MOCK_PID 2>/dev/null || true +sleep 1 + +cat > "$TMP/seconddns.conf" <> "$CALLS"\n' > "$TMP/queue"; chmod +x "$TMP/queue" + +zones() { printf '%s' "$1" > "$TMP/zones.json"; } +cli() { : > "$TMP/calls" + env SECONDDNS_CONF="$TMP/seconddns.conf" SECONDDNS_QUEUE_BIN="$TMP/queue" \ + SECONDDNS_DOMAIN_BIN="$HERE/../seconddns-domain" \ + SECONDDNS_PANEL_ZONES_CMD="${PANEL:-true}" CALLS="$TMP/calls" \ + python3 "$CLI" "$@" > "$TMP/out" 2>&1; } + +MINE='{"name":"%s","masterIp":"192.0.2.10","status":"synced"}' +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com)]" + +echo "== add and remove go through the queue" +cli add new.example.com +grep -q "enqueue create new.example.com 192.0.2.10" "$TMP/calls" && ok "add queues a create" || fail "add: $(cat "$TMP/calls")" +cli remove a.example.com +grep -q "enqueue delete a.example.com 192.0.2.10" "$TMP/calls" && ok "remove queues a delete" || fail "remove: $(cat "$TMP/calls")" +cli add ПРИКЛАД.УКР +grep -q "enqueue create xn--80aikifvh.xn--j1amh" "$TMP/calls" && ok "an IDN is queued in punycode" || fail "IDN: $(cat "$TMP/calls")" +cli add "not a domain"; rc=$? +[ "$rc" -ne 0 ] && [ ! -s "$TMP/calls" ] && ok "an unusable name is refused, nothing queued" || fail "unusable name accepted" + +echo "== sync only adds" +PANEL="printf '%s\n' a.example.com b.example.com c.example.com" cli sync +grep -q "enqueue create c.example.com" "$TMP/calls" && ok "sync queues the missing zone" || fail "sync missing" +grep -q "enqueue delete" "$TMP/calls" && fail "plain sync queued a delete" || ok "plain sync deletes nothing" + +echo "== sync --prune removes, within the guard" +# exactly half is allowed, more than half is not — check both sides of the line +PANEL="printf '%s\n' a.example.com" cli sync --prune; rc=$? +[ "$rc" -eq 0 ] && ok "half of two is allowed" || fail "refused exactly half (rc=$rc)" +grep -q "enqueue delete b.example.com" "$TMP/calls" && ok "the stale one is queued" || fail "half: $(cat "$TMP/calls")" + +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com),$(printf "$MINE" c.example.com)]" +PANEL="printf '%s\n' a.example.com" cli sync --prune; rc=$? +[ "$rc" -eq 2 ] && ok "two of three is refused" || fail "prune proceeded (rc=$rc)" +grep -q "enqueue delete" "$TMP/calls" && fail "queued a delete while refusing" || ok "nothing queued while refusing" + +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com),$(printf "$MINE" c.example.com),$(printf "$MINE" d.example.com)]" +PANEL="printf '%s\n' a.example.com b.example.com c.example.com" cli sync --prune +grep -q "enqueue delete d.example.com" "$TMP/calls" && ok "prunes one of four" || fail "prune of one: $(cat "$TMP/calls")" + +PANEL="printf '%s\n' a.example.com" cli sync --prune --force +n=$(grep -c "enqueue delete" "$TMP/calls") +[ "$n" -eq 3 ] && ok "--force allows the bulk prune" || fail "--force queued $n deletes, want 3" + +echo "== a zone mastered elsewhere is never touched" +zones '[{"name":"a.example.com","masterIp":"192.0.2.10","status":"synced"},{"name":"other.example.com","masterIp":"203.0.113.9","status":"synced"}]' +PANEL="printf '%s\n' a.example.com" cli sync --prune +grep -q "other.example.com" "$TMP/calls" && fail "queued a zone mastered elsewhere" || ok "another server's zone left alone" + +echo "== remove-all asks" +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com)]" +cli remove-all < /dev/null; rc=$? +[ "$rc" -eq 2 ] && ok "refuses without a confirmation" || fail "remove-all proceeded unconfirmed (rc=$rc)" +[ ! -s "$TMP/calls" ] && ok "nothing queued unconfirmed" || fail "queued: $(cat "$TMP/calls")" +cli remove-all --yes +n=$(grep -c "enqueue delete" "$TMP/calls") +[ "$n" -eq 2 ] && ok "--yes removes this server's zones" || fail "--yes queued $n, want 2" + +echo "== status and list read, never write" +cli list; grep -q "a.example.com" "$TMP/out" && ok "list shows the zones" || fail "list: $(cat "$TMP/out")" +[ ! -s "$TMP/calls" ] && ok "list queues nothing" || fail "list queued something" +cli status a.example.com; grep -q "mastered by 192.0.2.10" "$TMP/out" && ok "status reports one zone" || fail "status: $(cat "$TMP/out")" +cli status unknown.example.com; rc=$? +[ "$rc" -eq 1 ] && ok "an unknown zone exits 1" || fail "unknown zone rc=$rc" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index 96f7b08..a01c8ac 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -225,6 +225,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -233,7 +234,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") diff --git a/hosting-panels/cpanel/uninstall.sh b/hosting-panels/cpanel/uninstall.sh index 214760f..c809652 100755 --- a/hosting-panels/cpanel/uninstall.sh +++ b/hosting-panels/cpanel/uninstall.sh @@ -105,7 +105,7 @@ echo " Verify: $HOOKS_BIN list" systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/cyberpanel/install.sh b/hosting-panels/cyberpanel/install.sh index cefd42f..7ebb7e8 100755 --- a/hosting-panels/cyberpanel/install.sh +++ b/hosting-panels/cyberpanel/install.sh @@ -192,12 +192,6 @@ if [ -z "$MASTER_IP" ]; then fi fi -# Install CLI -echo "" -cp "$CYBER_SRC/seconddns.py" "$INSTALL_DIR/seconddns" -chmod +x "$INSTALL_DIR/seconddns" -echo "[+] Installed CLI to $INSTALL_DIR/seconddns" - # Create or update config # A bad master IP installs cleanly and then fails every zone with an opaque # HTTP 400 from the API, so refuse here instead. @@ -232,6 +226,8 @@ echo "[+] Log file: $LOG_FILE" # Install CyberPanel plugin if [ -d "$CYBERPANEL_DIR" ]; then cp "$CYBER_SRC/seconddns.py" "$PLUGIN_FILE" + # seconddns-signals.service runs it directly for ensure-signals + chmod +x "$PLUGIN_FILE" echo "[+] Installed plugin to $PLUGIN_FILE" # Clean and register signals @@ -273,6 +269,9 @@ with open(p, 'w') as f: f.write(c) # --- Offline operation queue --- COMMON_SRC="$WORK_DIR/dns_integrations/hosting-panels/common" +# the shared CLI, same commands and guards as on every other panel; +# seconddns.py stays behind as the Django plugin only +cp "$COMMON_SRC/seconddns" /usr/local/bin/seconddns cp "$COMMON_SRC/seconddns-domain" /usr/local/bin/seconddns-domain cp "$COMMON_SRC/seconddns-owner" /usr/local/bin/seconddns-owner cp "$COMMON_SRC/seconddns-migrate-master" /usr/local/bin/seconddns-migrate-master @@ -281,7 +280,7 @@ cp "$COMMON_SRC/seconddns_common.py" /usr/local/bin/seconddns_common.py cp "$COMMON_SRC/seconddns-queue" /usr/local/bin/seconddns-queue cp "$COMMON_SRC/seconddns-queued" /usr/local/bin/seconddns-queued cp "$COMMON_SRC/seconddns-queued.service" /etc/systemd/system/seconddns-queued.service -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash "$COMMON_SRC/install-idn2.sh" mkdir -p /var/lib/seconddns bash "$COMMON_SRC/install-sqlite.sh" diff --git a/hosting-panels/cyberpanel/seconddns-signals.service b/hosting-panels/cyberpanel/seconddns-signals.service index 3086e01..94ae9d2 100644 --- a/hosting-panels/cyberpanel/seconddns-signals.service +++ b/hosting-panels/cyberpanel/seconddns-signals.service @@ -5,7 +5,7 @@ BindsTo=lscpd.service [Service] Type=oneshot -ExecStart=/usr/local/bin/seconddns ensure-signals +ExecStart=/usr/local/CyberCP/plogical/seconddns_plugin.py ensure-signals RemainAfterExit=no [Install] diff --git a/hosting-panels/cyberpanel/seconddns.py b/hosting-panels/cyberpanel/seconddns.py index 49a9858..ed3e1c6 100755 --- a/hosting-panels/cyberpanel/seconddns.py +++ b/hosting-panels/cyberpanel/seconddns.py @@ -7,13 +7,9 @@ Works in two modes: 1. Django signal plugin — hooks into CyberPanel's postWebsiteCreation/Deletion signals - 2. CLI — manual add/remove/sync/list commands + 2. ensure-signals — re-register the Django signal handlers after a restart -CLI usage: - seconddns add — Register domain as secondary zone - seconddns remove — Remove domain from secondary DNS - seconddns sync — Sync all CyberPanel domains with secondary DNS - seconddns list — List zones on secondary DNS +Zone management from the command line is `seconddns`, shared by every panel. Configuration: /etc/seconddns.conf """ @@ -228,38 +224,7 @@ def get_cyberpanel_domains(): return domains -def sync(config): - local_domains = set(get_cyberpanel_domains()) - remote_zones = list_zones(config) - remote_domains = {z["name"].rstrip("."): z for z in remote_zones} - logger.info("Local domains: %d, Remote zones: %d", len(local_domains), len(remote_domains)) - - added = 0 - for domain in sorted(local_domains - set(remote_domains.keys())): - if add_zone(config, domain): - added += 1 - - removed = 0 - for domain in sorted(set(remote_domains.keys()) - local_domains): - if remove_zone(config, domain): - removed += 1 - - logger.info("Sync complete: +%d added, -%d removed", added, removed) - - -def cmd_list(config): - zones = list_zones(config) - if not zones: - print("No zones found.") - return - print(f"{'Name':<40} {'Status':<10} {'Master IP':<18} {'Last Sync'}") - print("-" * 90) - for z in zones: - print(f"{z['name']:<40} {z.get('status','?'):<10} {z.get('masterIp','?'):<18} {z.get('lastSync','never')}") - - -# --- Django signal handlers (used when loaded as CyberPanel plugin) --- def _extract_domain(request, response=None): """Extract domain name from CyberPanel request/response.""" @@ -536,15 +501,7 @@ def main(): if not config: sys.exit(1) - if cmd == "add" and len(sys.argv) >= 3: - add_zone(config, sys.argv[2]) - elif cmd == "remove" and len(sys.argv) >= 3: - remove_zone(config, sys.argv[2]) - elif cmd == "sync": - sync(config) - elif cmd == "list": - cmd_list(config) - elif cmd == "ensure-signals": + if cmd == "ensure-signals": ensure_signals() else: print(__doc__) diff --git a/hosting-panels/cyberpanel/uninstall.sh b/hosting-panels/cyberpanel/uninstall.sh index e0f94f4..4c2e93d 100755 --- a/hosting-panels/cyberpanel/uninstall.sh +++ b/hosting-panels/cyberpanel/uninstall.sh @@ -92,7 +92,7 @@ echo "Delete them manually via Dashboard or API if needed." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/directadmin/install.sh b/hosting-panels/directadmin/install.sh index ff052fb..eb3669e 100644 --- a/hosting-panels/directadmin/install.sh +++ b/hosting-panels/directadmin/install.sh @@ -222,6 +222,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -230,7 +231,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") diff --git a/hosting-panels/directadmin/uninstall.sh b/hosting-panels/directadmin/uninstall.sh index cb5a234..0d00002 100644 --- a/hosting-panels/directadmin/uninstall.sh +++ b/hosting-panels/directadmin/uninstall.sh @@ -36,7 +36,7 @@ echo " Your zones on the secondary DNS are not deleted automatically." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/plesk/install.sh b/hosting-panels/plesk/install.sh index 14b9b97..a5507a2 100755 --- a/hosting-panels/plesk/install.sh +++ b/hosting-panels/plesk/install.sh @@ -228,6 +228,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -236,7 +237,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") diff --git a/hosting-panels/plesk/uninstall.sh b/hosting-panels/plesk/uninstall.sh index 7589aa1..8b7bb71 100755 --- a/hosting-panels/plesk/uninstall.sh +++ b/hosting-panels/plesk/uninstall.sh @@ -126,7 +126,7 @@ echo " Your zones on the secondary DNS are not deleted automatically." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" From 8379c462011fffc580821297c0dceaa8d5095931 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 11:03:59 +0200 Subject: [PATCH 2/9] Drop cPanel's own sync, and keep the server's hostname out of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing on the live cPanel box ran the initial fill twice: #80 added the reconcile-based one, and cPanel already had its own under a different name — "Sync existing cPanel accounts", which my grep for "Queue existing domains" did not match. I reported that cPanel had no initial fill at all. It had one, and a worse one: - it POSTs to the API directly, so an outage during install loses the zones instead of queueing them - no canonicalisation, so an IDN goes as whatever the panel stored - it reads DNS= from /var/cpanel/users, which is the account's main domain only — addon and parked domains are not there Removed; the reconcile path covers all of it. Second, on the same install: whmapi1 listzones includes the server's own hostname zone, which the API refuses (it is under the service's domain). Every cPanel install therefore ended with a failed op in the queue and an alarming line in the log, on a zone that was never the customer's. panel_zones() now drops the zone whose name is the machine's FQDN, for every panel. tests/test_hostname_filter.sh: the hostname zone goes, case and trailing dot included; the parent zone and a subdomain of it stay; a hostname without a dot drops nothing; an empty panel stays empty. Mutations: removing the filter fails 3, comparing without normalising fails 2. Live on the cPanel box: panel_zones() now returns only the customer zone and reconcile reports ok: 1, missing: 0. --- .github/workflows/tests.yml | 3 ++ hosting-panels/common/seconddns_common.py | 19 ++++++++ .../common/tests/test_hostname_filter.sh | 44 +++++++++++++++++++ hosting-panels/cpanel/install.sh | 33 -------------- 4 files changed, 66 insertions(+), 33 deletions(-) create mode 100755 hosting-panels/common/tests/test_hostname_filter.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 64c8eed..7f1b219 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,9 @@ jobs: - name: test_cli.sh run: bash hosting-panels/common/tests/test_cli.sh + - name: test_hostname_filter.sh + run: bash hosting-panels/common/tests/test_hostname_filter.sh + - name: test_cyberpanel_db.sh run: bash hosting-panels/common/tests/test_cyberpanel_db.sh diff --git a/hosting-panels/common/seconddns_common.py b/hosting-panels/common/seconddns_common.py index 3974751..7121f57 100644 --- a/hosting-panels/common/seconddns_common.py +++ b/hosting-panels/common/seconddns_common.py @@ -5,6 +5,7 @@ import configparser import json import os +import socket import subprocess import sys import urllib.error @@ -163,11 +164,29 @@ def panel_zones(): zones = [l.strip() for l in (out or "").splitlines() if l.strip()] if out is None: raise PanelError(f"could not read the zone list ({source}); refusing to continue") + # The server's own hostname zone belongs to the host, not to the customer: + # cPanel keeps one and lists it, and every install then queues a create the + # API refuses. Add it by hand with `seconddns add` if it is really wanted. + host = _server_hostname() + if host: + zones = [z for z in zones if z.lower().rstrip(".") != host] # A panel with no zones is a legal state — a fresh server, or one that was # emptied. Only a failed read is an error, and that is the branch above. return zones +def _server_hostname(): + """This machine's fully qualified name, lowercased, or '' if unknown.""" + name = os.environ.get("SECONDDNS_HOSTNAME") # tests + if name is None: + try: + name = socket.getfqdn() + except OSError: + return "" + name = (name or "").strip().lower().rstrip(".") + return name if "." in name else "" + + def zones_from_file(path): zones = [l.strip() for l in open(path) if l.strip() and not l.startswith("#")] if not zones: diff --git a/hosting-panels/common/tests/test_hostname_filter.sh b/hosting-panels/common/tests/test_hostname_filter.sh new file mode 100755 index 0000000..91e3e3b --- /dev/null +++ b/hosting-panels/common/tests/test_hostname_filter.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# The server's own hostname zone is not a customer zone: cPanel lists it and the +# API refuses it, so every install left a failed op behind. +# Run: bash hosting-panels/common/tests/test_hostname_filter.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +zones() { # $1 = hostname, $2 = panel zone list + env SECONDDNS_HOSTNAME="$1" SECONDDNS_PANEL_ZONES_CMD="printf '%s\n' $2" \ + PYTHONPATH="$HERE/.." python3 -c " +from seconddns_common import panel_zones +print(' '.join(panel_zones())) +" 2>&1 +} + +echo "== the hostname zone is dropped" +out=$(zones "server1.seconddns.com" "a.example.com server1.seconddns.com b.example.com") +[ "$out" = "a.example.com b.example.com" ] && ok "hostname removed, order kept" || fail "got '$out'" + +echo "== matching is case- and dot-insensitive" +out=$(zones "server1.seconddns.com" "SERVER1.SecondDNS.com a.example.com") +[ "$out" = "a.example.com" ] && ok "uppercase form removed" || fail "got '$out'" +out=$(zones "server1.seconddns.com" "server1.seconddns.com. a.example.com") +[ "$out" = "a.example.com" ] && ok "trailing dot removed" || fail "got '$out'" + +echo "== only the exact name goes" +out=$(zones "server1.seconddns.com" "seconddns.com sub.server1.seconddns.com a.example.com") +[ "$out" = "seconddns.com sub.server1.seconddns.com a.example.com" ] \ + && ok "parent and child zones kept" || fail "got '$out'" + +echo "== a bare hostname is not a zone name" +out=$(zones "localhost" "localhost a.example.com") +[ "$out" = "localhost a.example.com" ] && ok "no dot, nothing dropped" || fail "got '$out'" + +echo "== an empty panel stays empty, not an error" +out=$(zones "server1.seconddns.com" "") +[ -z "$out" ] && ok "empty list survives the filter" || fail "got '$out'" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index a01c8ac..0d5ca92 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -503,39 +503,6 @@ if [ -n "$API_NS" ]; then fi fi -# Initial sync -echo "" -if confirm "Sync existing cPanel accounts to secondary DNS now?"; then - echo "[*] Syncing accounts..." - added=0 - failed=0 - - if [ -d /var/cpanel/users ]; then - for user_file in /var/cpanel/users/*; do - [ -f "$user_file" ] || continue - sdomain=$(grep "^DNS=" "$user_file" 2>/dev/null | cut -d= -f2) - [ -z "$sdomain" ] && continue - - response=$(curl -sf --max-time 15 \ - -X POST \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -H "User-Agent: SecondDNS-cPanel/1.0" \ - -d "{\"name\":\"$sdomain\",\"masterIp\":\"$MASTER_IP\"}" \ - "$API_URL/api/zones" 2>/dev/null) - if [ $? -eq 0 ]; then - echo " [+] $sdomain" - added=$((added+1)) - else - failed=$((failed+1)) - fi - done - else - echo "[!] /var/cpanel/users not found — skipping sync" - fi - - echo "[+] Synced: $added domains, failed: $failed" -fi echo "" # reconcile already knows where each panel keeps its zone list; --add-missing From f23e1422058fc4145996511fbbb4754c969f0b29 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 11:07:47 +0200 Subject: [PATCH 3/9] Two-line comments over the hostname filter and its test --- hosting-panels/common/seconddns_common.py | 5 ++--- hosting-panels/common/tests/test_hostname_filter.sh | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/hosting-panels/common/seconddns_common.py b/hosting-panels/common/seconddns_common.py index 7121f57..868c3c9 100644 --- a/hosting-panels/common/seconddns_common.py +++ b/hosting-panels/common/seconddns_common.py @@ -164,9 +164,8 @@ def panel_zones(): zones = [l.strip() for l in (out or "").splitlines() if l.strip()] if out is None: raise PanelError(f"could not read the zone list ({source}); refusing to continue") - # The server's own hostname zone belongs to the host, not to the customer: - # cPanel keeps one and lists it, and every install then queues a create the - # API refuses. Add it by hand with `seconddns add` if it is really wanted. + # The server's own hostname zone belongs to the host, not the customer; the + # API refuses it, so every cPanel install left a failed op behind. host = _server_hostname() if host: zones = [z for z in zones if z.lower().rstrip(".") != host] diff --git a/hosting-panels/common/tests/test_hostname_filter.sh b/hosting-panels/common/tests/test_hostname_filter.sh index 91e3e3b..7668da5 100755 --- a/hosting-panels/common/tests/test_hostname_filter.sh +++ b/hosting-panels/common/tests/test_hostname_filter.sh @@ -1,6 +1,6 @@ #!/bin/bash -# The server's own hostname zone is not a customer zone: cPanel lists it and the -# API refuses it, so every install left a failed op behind. +# The server's own hostname is not a customer zone: cPanel lists it, the API +# refuses it, and every install left a failed op behind. # Run: bash hosting-panels/common/tests/test_hostname_filter.sh set -u HERE="$(cd "$(dirname "$0")" && pwd)" From 3f6ede49bcd0e1c1d0ec6d9955d266d8831578e0 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 11:23:12 +0200 Subject: [PATCH 4/9] Restart PowerDNS instead of reloading it, and notice when it stays down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the live cPanel box the zone reached SecondDNS, the worker created it on ns3, and then sat at pending forever: 08:57:12 done queue=pdns.zone-add zone=my123domain.com 08:57:12 notify empty, staying pending AXFR was refused. The installer had written allow-axfr-ips into pdns.conf and run `systemctl reload pdns` — and a reload rereads the zones, not the server settings. The setting sat in the file with no effect until something restarted the service. A customer would see zones registered in SecondDNS and empty on the secondary, with nothing to explain it. Proven in place: `dig AXFR` refused after the reload, the same query answered after `systemctl restart pdns`, and my123domain.com moved pending -> synced within seconds. cPanel was the only installer using reload; CyberPanel and DirectAdmin already restart. Writing the guard turned up a second one: DirectAdmin restarted PowerDNS and reported success without checking it came back, so a config it could not parse would leave DNS down under a "[+] configured and restarted". It now verifies and restores the backup, as CyberPanel does. tests/test_pdns_restart.sh asserts, for every installer that touches pdns.conf: no reload, a restart, a message when it stays down, and a rollback to the backup. Lower bound of 3 installers so a rename fails instead of matching nothing. Mutations: restoring the reload fails 1, dropping the down-message fails 2, dropping the rollback fails 1. --- .github/workflows/tests.yml | 3 ++ .../common/tests/test_pdns_restart.sh | 44 +++++++++++++++++++ hosting-panels/cpanel/install.sh | 21 ++++++--- hosting-panels/directadmin/install.sh | 15 ++++++- 4 files changed, 74 insertions(+), 9 deletions(-) create mode 100755 hosting-panels/common/tests/test_pdns_restart.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 64c8eed..732e041 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,9 @@ jobs: - name: test_cli.sh run: bash hosting-panels/common/tests/test_cli.sh + - name: test_pdns_restart.sh + run: bash hosting-panels/common/tests/test_pdns_restart.sh + - name: test_cyberpanel_db.sh run: bash hosting-panels/common/tests/test_cyberpanel_db.sh diff --git a/hosting-panels/common/tests/test_pdns_restart.sh b/hosting-panels/common/tests/test_pdns_restart.sh new file mode 100755 index 0000000..b353481 --- /dev/null +++ b/hosting-panels/common/tests/test_pdns_restart.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# PowerDNS rereads zones on a reload but not allow-axfr-ips, so a reload after +# editing pdns.conf leaves AXFR refused and every zone stuck pending. +# Run: bash hosting-panels/common/tests/test_pdns_restart.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PANELS="cpanel cyberpanel directadmin plesk" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +checked=0 +for panel in $PANELS; do + src="$HERE/../../$panel/install.sh" + [ -f "$src" ] || { echo "missing $src"; exit 1; } + grep -q "pdns" "$src" || continue + checked=$((checked+1)) + echo "== $panel" + + if grep -qE "systemctl reload pdns|pdns_control reload|service pdns reload" "$src"; then + fail "reloads PowerDNS after editing pdns.conf" + else + ok "no PowerDNS reload" + fi + + # editing the config at all obliges a restart + if grep -q 'allow-axfr-ips' "$src"; then + grep -qE "(systemctl|service) .*pdns.* restart|systemctl restart pdns" "$src" \ + && ok "restarts PowerDNS" || fail "edits allow-axfr-ips without restarting" + # a restart that does not come back must not be reported as success: + # the config is restored and the operator told, not a cheerful "[+]" + grep -q 'did not come back' "$src" \ + && ok "reports a PowerDNS that stayed down" || fail "a dead PowerDNS would be reported as success" + grep -qE 'cp "[^"]*(BAK|bak)[^"]*" "\$PDNS_CONF"' "$src" \ + && ok "restores the config on a failed restart" || fail "no rollback on a failed restart" + fi +done + +# a lower bound: a rename that stops matching the installers fails here +[ "$checked" -ge 3 ] && ok "checked $checked installers" || fail "only $checked installers matched" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index a01c8ac..14b3fe9 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -351,7 +351,8 @@ else if [ ! -f "$PDNS_CONF" ]; then echo "[!] pdns.conf not found at $PDNS_CONF — configure AXFR manually" else - cp "$PDNS_CONF" "${PDNS_CONF}.bak.$(date +%s)" + PDNS_BAK="${PDNS_CONF}.bak.$(date +%s)" + cp "$PDNS_CONF" "$PDNS_BAK" # primary / master mode (name differs by pdns version) if grep -q "^master=" "$PDNS_CONF" 2>/dev/null; then @@ -401,13 +402,19 @@ else echo "[+] PowerDNS: also-notify=$SECONDARY_IP" fi - # Reload - if systemctl reload pdns &>/dev/null 2>&1; then - echo "[+] PowerDNS reloaded" - elif pdns_control reload &>/dev/null 2>&1; then - echo "[+] PowerDNS reloaded via pdns_control" + # A reload rereads the zones, not the settings above: allow-axfr-ips + # only takes effect on a restart, and until then AXFR is refused. + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + sleep 2 + if systemctl is-active --quiet pdns 2>/dev/null || pgrep -x pdns_server >/dev/null; then + echo "[+] PowerDNS configured and restarted" else - echo "[!] Restart PowerDNS manually: systemctl restart pdns" + # Saying "OK" while the DNS server is down is worse than + # failing: restore what was there and let a human look. + [ -n "${PDNS_BAK:-}" ] && cp "$PDNS_BAK" "$PDNS_CONF" + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + echo "[!] PowerDNS did not come back — configuration restored from the backup" + echo "[!] Check: journalctl -u pdns -n 20" fi fi diff --git a/hosting-panels/directadmin/install.sh b/hosting-panels/directadmin/install.sh index eb3669e..94c3d27 100644 --- a/hosting-panels/directadmin/install.sh +++ b/hosting-panels/directadmin/install.sh @@ -344,7 +344,8 @@ if [ -n "$DNS_IPS" ]; then if [ "$ISSUES" -gt 0 ]; then if confirm "Apply PowerDNS fixes automatically? (backup will be created)"; then - cp "$PDNS_CONF" "${PDNS_CONF}.bak.$(date +%s)" + PDNS_BAK="${PDNS_CONF}.bak.$(date +%s)" + cp "$PDNS_CONF" "$PDNS_BAK" grep -qE "^master=yes" "$PDNS_CONF" || echo "master=yes" >> "$PDNS_CONF" grep -qE "^default-soa-edit=" "$PDNS_CONF" || echo "default-soa-edit=INCEPTION-INCREMENT" >> "$PDNS_CONF" @@ -364,7 +365,17 @@ if [ -n "$DNS_IPS" ]; then fi systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null - echo "[+] PowerDNS configured and restarted" + sleep 2 + if systemctl is-active --quiet pdns 2>/dev/null || pgrep -x pdns_server >/dev/null; then + echo "[+] PowerDNS configured and restarted" + else + # Saying "OK" while the DNS server is down is worse than + # failing: restore what was there and let a human look. + cp "$PDNS_BAK" "$PDNS_CONF" + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + echo "[!] PowerDNS did not come back — configuration restored from the backup" + echo "[!] Check: journalctl -u pdns -n 20" + fi fi else echo "[+] PowerDNS AXFR config OK" From b873dcc91ad31ca6f8ab34b522d72291951552cd Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 11:40:23 +0200 Subject: [PATCH 5/9] Read the cPanel hook payload where cPanel puts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating an account on the live box produced nothing: no queue entry, no log line, not even an error. The zone existed in PowerDNS and the hook was registered on Accounts::Create at stage post, pointing at a script that worked when run by hand. The script reads stdin and looks for "domain" at the top level. cPanel sends: {"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"}, "hook":{...}, "data":{"user":"acct","domain":"second555domain.org", ...}} The name is under "data". Finding nothing, the hook exits 0 without logging — so account creation on cPanel has never reached SecondDNS, silently. The same shape applies to the delete hook and to addon domains. That also explains the installer's own account sync removed in #88: with the hooks not firing, something had to register the zones. Both hooks now read "data" when it is an object and fall back to the top level. tests/test_cpanel_hook_payload.sh extracts the parser from each hook — so the test cannot drift from the code — and feeds it the real WHM payload, the flat shape, a payload with no domain, malformed input, and an addon domain arriving as newdomain. Mutations: reading the top level fails 2, dropping newdomain fails 1. --- .github/workflows/tests.yml | 3 ++ .../common/tests/test_cpanel_hook_payload.sh | 50 +++++++++++++++++++ hosting-panels/cpanel/domain_create.sh | 4 +- hosting-panels/cpanel/domain_delete.sh | 4 +- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100755 hosting-panels/common/tests/test_cpanel_hook_payload.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e21f66..1bf6abe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,6 +62,9 @@ jobs: - name: test_pdns_restart.sh run: bash hosting-panels/common/tests/test_pdns_restart.sh + - name: test_cpanel_hook_payload.sh + run: bash hosting-panels/common/tests/test_cpanel_hook_payload.sh + - name: test_cyberpanel_db.sh run: bash hosting-panels/common/tests/test_cyberpanel_db.sh diff --git a/hosting-panels/common/tests/test_cpanel_hook_payload.sh b/hosting-panels/common/tests/test_cpanel_hook_payload.sh new file mode 100755 index 0000000..6f5c6e0 --- /dev/null +++ b/hosting-panels/common/tests/test_cpanel_hook_payload.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# cPanel nests the event under "data"; reading the top level finds nothing and +# the hook exits silently, which is how account creation went unnoticed. +# Run: bash hosting-panels/common/tests/test_cpanel_hook_payload.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +# the parser as the hooks embed it, extracted so the test cannot drift from them +extract() { # $1 = hook file, $2 = json + local py + py=$(sed -n '/^import sys, json$/,/^ pass$/p' "$1") + [ -n "$py" ] || { echo "PARSER-NOT-FOUND"; return; } + printf '%s' "$2" | python3 -c "$py" +} + +for hook in domain_create domain_delete; do + f="$HERE/../../cpanel/$hook.sh" + [ -f "$f" ] || { echo "missing $f"; exit 1; } + echo "== $hook" + + # what WHM actually sends on Accounts::Create, trimmed to shape + real='{"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"},"hook":{"stage":"post","exectype":"script"},"data":{"user":"acct","domain":"real.example.com","plan":"default"}}' + [ "$(extract "$f" "$real")" = "real.example.com" ] \ + && ok "reads domain from data" || fail "got '$(extract "$f" "$real")'" + + # the top-level shape must keep working + flat='{"domain":"flat.example.com"}' + [ "$(extract "$f" "$flat")" = "flat.example.com" ] \ + && ok "still reads a top-level domain" || fail "flat shape broken" + + # context carries an unrelated "domain"-free payload: nothing to do + empty='{"context":{"event":"Accounts::Create"},"hook":{},"data":{"user":"acct","plan":"default"}}' + [ -z "$(extract "$f" "$empty")" ] \ + && ok "no domain in data yields nothing" || fail "invented a name" + + [ -z "$(extract "$f" 'not json')" ] && ok "malformed input yields nothing" || fail "malformed input" +done + +echo "== an addon domain arrives as newdomain" +f="$HERE/../../cpanel/domain_create.sh" +addon='{"context":{"event":"Api2::AddonDomain::addaddon"},"data":{"newdomain":"addon.example.com","user":"acct"}}' +[ "$(extract "$f" "$addon")" = "addon.example.com" ] \ + && ok "reads newdomain from data" || fail "got '$(extract "$f" "$addon")'" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/domain_create.sh b/hosting-panels/cpanel/domain_create.sh index d77c47e..bd3ead2 100755 --- a/hosting-panels/cpanel/domain_create.sh +++ b/hosting-panels/cpanel/domain_create.sh @@ -25,7 +25,9 @@ ZONE_NAME=$(echo "$STDIN_DATA" | python3 -c " import sys, json try: d = json.load(sys.stdin) - print(d.get('domain') or d.get('newdomain') or '') + # cPanel nests the event under "data"; older shapes put it at the top level + src = d.get('data') if isinstance(d.get('data'), dict) else d + print(src.get('domain') or src.get('newdomain') or '') except Exception: pass " 2>/dev/null) diff --git a/hosting-panels/cpanel/domain_delete.sh b/hosting-panels/cpanel/domain_delete.sh index b2993ba..e9a87d7 100755 --- a/hosting-panels/cpanel/domain_delete.sh +++ b/hosting-panels/cpanel/domain_delete.sh @@ -24,7 +24,9 @@ ZONE_NAME=$(echo "$STDIN_DATA" | python3 -c " import sys, json try: d = json.load(sys.stdin) - print(d.get('domain') or '') + # cPanel nests the event under "data"; older shapes put it at the top level + src = d.get('data') if isinstance(d.get('data'), dict) else d + print(src.get('domain') or src.get('newdomain') or '') except Exception: pass " 2>/dev/null) From 70910244176a8af0bbb1a0596e33ce72bdbe4be2 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 12:18:37 +0200 Subject: [PATCH 6/9] Take every domain of a removed account, and hook the events cPanel fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more, both found on the live box after the payload fix. Accounts::Remove carries only the user: "data":{"killdns":1,"user":"second444mydom","keep_remote_databases":0} There is no domain to read, so deleting an account removed nothing. The installer already registers this at stage pre — the comment says "domain data available before account is gone" — but the hook never used it. It now reads userdata//main and queues every domain of the account: main, addon and parked alike. Parsed by hand rather than through PyYAML, which is not a dependency anywhere else. And addon domains never fired at all: the installer registered Api2::AddonDomain::addaddon, an event current cPanel does not have. Addon, parked and alias domains all go through Whostmgr::Domain::park / ::unpark, which carry target_domain (the account's main domain) and new_domain (the one being added). Reading "domain" would have registered the wrong name. Both events are now registered; the Api2 pair stays as a fallback for older cPanel. Live, deleting an account with three domains: 10:13:59 Zone deleted: addon4444domain.info (addon) 10:13:59 Zone deleted: second777domain.com (main) 10:13:59 Zone deleted: second1123domain.net (alias) 10:14:25 all delivered Tests cover the park payload for both hooks, every domain of an account from userdata, the DNS= fallback, an unknown user yielding nothing, and an explicit domain still winning. Mutation: dropping new_domain fails 1 — the hook would take target_domain, registering the account's main domain in place of the addon. --- .../common/tests/test_cpanel_hook_payload.sh | 75 +++++++++++++------ hosting-panels/cpanel/domain_create.sh | 2 +- hosting-panels/cpanel/domain_delete.sh | 63 +++++++++++++--- hosting-panels/cpanel/install.sh | 24 ++++-- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/hosting-panels/common/tests/test_cpanel_hook_payload.sh b/hosting-panels/common/tests/test_cpanel_hook_payload.sh index 6f5c6e0..be9110b 100755 --- a/hosting-panels/common/tests/test_cpanel_hook_payload.sh +++ b/hosting-panels/common/tests/test_cpanel_hook_payload.sh @@ -1,6 +1,6 @@ #!/bin/bash -# cPanel nests the event under "data"; reading the top level finds nothing and -# the hook exits silently, which is how account creation went unnoticed. +# cPanel nests the event under "data", and Accounts::Remove carries only the +# user — reading the top level finds nothing and the hook exits silently. # Run: bash hosting-panels/common/tests/test_cpanel_hook_payload.sh set -u HERE="$(cd "$(dirname "$0")" && pwd)" @@ -8,42 +8,69 @@ PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ok: $1"; } fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } -# the parser as the hooks embed it, extracted so the test cannot drift from them -extract() { # $1 = hook file, $2 = json +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +# the parser as each hook embeds it, so the test cannot drift from the code +extract() { # $1 hook file, $2 json local py - py=$(sed -n '/^import sys, json$/,/^ pass$/p' "$1") + py=$(sed -n '/^import sys, json/,/^ pass$/p' "$1") [ -n "$py" ] || { echo "PARSER-NOT-FOUND"; return; } printf '%s' "$2" | python3 -c "$py" } +REAL='{"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"},"hook":{"exectype":"script"},"data":{"user":"acct","domain":"real.example.com","plan":"default"}}' + for hook in domain_create domain_delete; do f="$HERE/../../cpanel/$hook.sh" [ -f "$f" ] || { echo "missing $f"; exit 1; } echo "== $hook" - - # what WHM actually sends on Accounts::Create, trimmed to shape - real='{"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"},"hook":{"stage":"post","exectype":"script"},"data":{"user":"acct","domain":"real.example.com","plan":"default"}}' - [ "$(extract "$f" "$real")" = "real.example.com" ] \ - && ok "reads domain from data" || fail "got '$(extract "$f" "$real")'" - - # the top-level shape must keep working - flat='{"domain":"flat.example.com"}' - [ "$(extract "$f" "$flat")" = "flat.example.com" ] \ + [ "$(extract "$f" "$REAL")" = "real.example.com" ] \ + && ok "reads domain from data" || fail "got '$(extract "$f" "$REAL")'" + [ "$(extract "$f" '{"domain":"flat.example.com"}')" = "flat.example.com" ] \ && ok "still reads a top-level domain" || fail "flat shape broken" - - # context carries an unrelated "domain"-free payload: nothing to do - empty='{"context":{"event":"Accounts::Create"},"hook":{},"data":{"user":"acct","plan":"default"}}' - [ -z "$(extract "$f" "$empty")" ] \ - && ok "no domain in data yields nothing" || fail "invented a name" - [ -z "$(extract "$f" 'not json')" ] && ok "malformed input yields nothing" || fail "malformed input" done echo "== an addon domain arrives as newdomain" -f="$HERE/../../cpanel/domain_create.sh" -addon='{"context":{"event":"Api2::AddonDomain::addaddon"},"data":{"newdomain":"addon.example.com","user":"acct"}}' -[ "$(extract "$f" "$addon")" = "addon.example.com" ] \ - && ok "reads newdomain from data" || fail "got '$(extract "$f" "$addon")'" +[ "$(extract "$HERE/../../cpanel/domain_create.sh" '{"data":{"newdomain":"addon.example.com"}}')" = "addon.example.com" ] \ + && ok "reads newdomain from data" || fail "newdomain not read" + +echo "== Accounts::Remove carries only the user" +DEL="$HERE/../../cpanel/domain_delete.sh" +REMOVE='{"context":{"event":"Accounts::Remove","stage":"pre"},"data":{"user":"acct","killdns":1}}' + +mkdir -p "$TMP/userdata/acct" +cat > "$TMP/userdata/acct/main" <<'YAML' +--- +addon_domains: + addon.example.com: addon.main.example.com +main_domain: main.example.com +parked_domains: + - parked.example.com +sub_domains: [] +YAML +got=$(SECONDDNS_CPANEL_ROOT="$TMP" extract "$DEL" "$REMOVE" | sort | tr '\n' ' ') +[ "$got" = "addon.example.com main.example.com parked.example.com " ] \ + && ok "every domain of the account is listed" || fail "got '$got'" + +FB="$(mktemp -d)"; mkdir -p "$FB/users" +printf 'DNS=fallback.example.com\nUSER=acct\n' > "$FB/users/acct" +got=$(SECONDDNS_CPANEL_ROOT="$FB" extract "$DEL" "$REMOVE" | tr '\n' ' ') +[ "$got" = "fallback.example.com " ] && ok "falls back to the user file" || fail "fallback got '$got'" +rm -rf "$FB" + +got=$(SECONDDNS_CPANEL_ROOT="$TMP" extract "$DEL" '{"data":{"user":"nosuch"}}') +[ -z "$got" ] && ok "unknown user yields nothing" || fail "invented '$got'" + +got=$(SECONDDNS_CPANEL_ROOT="$TMP" extract "$DEL" "$REAL") +[ "$got" = "real.example.com" ] && ok "an explicit domain still wins" || fail "explicit domain got '$got'" + +echo "== park sends new_domain" +PARK='{"context":{"category":"Whostmgr","event":"Domain::park","stage":"post"},"data":{"target_domain":"main.example.com","new_domain":"addon.example.com","user":"acct"}}' +[ "$(extract "$HERE/../../cpanel/domain_create.sh" "$PARK")" = "addon.example.com" ] \ + && ok "create reads new_domain, not the target" || fail "create got '$(extract "$HERE/../../cpanel/domain_create.sh" "$PARK")'" +[ "$(extract "$DEL" "$PARK")" = "addon.example.com" ] \ + && ok "delete reads new_domain, not the target" || fail "delete got '$(extract "$DEL" "$PARK")'" echo echo "passed=$PASS failed=$FAIL" diff --git a/hosting-panels/cpanel/domain_create.sh b/hosting-panels/cpanel/domain_create.sh index bd3ead2..689f5e7 100755 --- a/hosting-panels/cpanel/domain_create.sh +++ b/hosting-panels/cpanel/domain_create.sh @@ -27,7 +27,7 @@ try: d = json.load(sys.stdin) # cPanel nests the event under "data"; older shapes put it at the top level src = d.get('data') if isinstance(d.get('data'), dict) else d - print(src.get('domain') or src.get('newdomain') or '') + print(src.get('new_domain') or src.get('domain') or src.get('newdomain') or '') except Exception: pass " 2>/dev/null) diff --git a/hosting-panels/cpanel/domain_delete.sh b/hosting-panels/cpanel/domain_delete.sh index e9a87d7..9304010 100755 --- a/hosting-panels/cpanel/domain_delete.sh +++ b/hosting-panels/cpanel/domain_delete.sh @@ -20,26 +20,71 @@ MASTER_IP=$(grep "^master_ip" "$CONFIG" | sed 's/^master_ip[[:space:]]*=[[:space [ -z "$API_URL" ] || [ -z "$API_KEY" ] && exit 0 STDIN_DATA=$(cat) -ZONE_NAME=$(echo "$STDIN_DATA" | python3 -c " -import sys, json +# Accounts::Remove carries only the user: every domain of the account has to be +# read from its userdata, which stage "pre" still has on disk. +ZONE_NAMES=$(echo "$STDIN_DATA" | python3 -c " +import sys, json, os +def account_domains(user): + # userdata//main lists every domain of the account; parsed by hand so + # the hook does not depend on PyYAML being installed + root = os.environ.get('SECONDDNS_CPANEL_ROOT', '/var/cpanel') + names, section = [], None + try: + with open('%s/userdata/%s/main' % (root, user)) as fh: + for line in fh: + line = line.rstrip('\n') + if not line or line.startswith('---'): + continue + if not line[:1].isspace(): + key, _, value = line.partition(':') + section, value = key.strip(), value.strip() + if section == 'main_domain' and value: + names.append(value) + continue + item = line.strip() + if section == 'addon_domains' and ':' in item: + names.append(item.split(':', 1)[0].strip()) + elif section == 'parked_domains' and item.startswith('- '): + names.append(item[2:].strip()) + except Exception: + try: + with open('%s/users/%s' % (root, user)) as fh: + for line in fh: + if line.startswith('DNS'): + names.append(line.split('=', 1)[1].strip()) + except Exception: + pass + return [n for n in names if n] + try: d = json.load(sys.stdin) - # cPanel nests the event under "data"; older shapes put it at the top level src = d.get('data') if isinstance(d.get('data'), dict) else d - print(src.get('domain') or src.get('newdomain') or '') + one = src.get('new_domain') or src.get('domain') or src.get('newdomain') + if one: + print(one) + elif src.get('user'): + for n in account_domains(src['user']): + print(n) except Exception: pass " 2>/dev/null) -[ -z "$ZONE_NAME" ] && exit 0 +[ -z "$ZONE_NAMES" ] && exit 0 DOMAIN_LIB="/usr/local/bin/seconddns-domain" [ -r "$DOMAIN_LIB" ] || { log "[!] $DOMAIN_LIB missing, cannot validate zone name"; exit 0; } SECONDDNS_DOMAIN_LIB=1 . "$DOMAIN_LIB" + +OWNER_LIB="/usr/local/bin/seconddns-owner" +[ -r "$OWNER_LIB" ] && SECONDDNS_OWNER_LIB=1 . "$OWNER_LIB" +QUEUE="/usr/local/bin/seconddns-queue" + +echo "$ZONE_NAMES" | while IFS= read -r ZONE_NAME; do +[ -z "$ZONE_NAME" ] && continue RAW_NAME="$ZONE_NAME" if ! canonical_domain "$ZONE_NAME"; then log "[!] Zone '$ZONE_NAME' refused: $DOMAIN_ERROR (cpanel hook)" - exit 0 + continue fi ZONE_NAME="$DOMAIN" # keep what the panel actually handed over, for later diagnosis @@ -47,22 +92,20 @@ RAW_NOTE=""; [ "$RAW_NAME" != "$ZONE_NAME" ] && RAW_NOTE=" (received as '$RAW_NA log "Zone deleted: $ZONE_NAME (cpanel hook)$RAW_NOTE" -OWNER_LIB="/usr/local/bin/seconddns-owner" -[ -r "$OWNER_LIB" ] && SECONDDNS_OWNER_LIB=1 . "$OWNER_LIB" if type owner_check >/dev/null 2>&1; then owner_check "$ZONE_NAME" "$MASTER_IP" case $? in - 1) log "[~] Zone $ZONE_NAME is mastered by $OWNER_IP, not this server — delete skipped"; exit 0 ;; + 1) log "[~] Zone $ZONE_NAME is mastered by $OWNER_IP, not this server — delete skipped"; continue ;; 2) log "[~] Zone $ZONE_NAME owner check: API unreachable, queued; checked again at delivery" ;; 4) log "[!] Zone $ZONE_NAME owner check skipped: api_url/api_key/master_ip missing in config, queued WITHOUT check" ;; esac fi -QUEUE="/usr/local/bin/seconddns-queue" if "$QUEUE" enqueue delete "$ZONE_NAME" "$MASTER_IP"; then log "[>] Zone $ZONE_NAME removal queued for SecondDNS" else log "[!] Zone $ZONE_NAME removal NOT queued (seconddns-queue failed)" fi +done exit 0 diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index fbc37ea..c39cc9b 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -272,24 +272,32 @@ else echo "[!] Failed: Whostmgr::Accounts::Remove" fi -# cPanel addon domain create (post) +# Addon, parked and alias domains all go through park/unpark; the Api2 +# AddonDomain events are not fired by current cPanel and are kept as a fallback. if "$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_create.sh" \ - --category Api2 --event AddonDomain::addaddon --stage post 2>/dev/null; then - echo "[+] Registered: Api2::AddonDomain::addaddon (post)" + --category Whostmgr --event Domain::park --stage post 2>/dev/null; then + echo "[+] Registered: Whostmgr::Domain::park (post)" REGISTERED=$((REGISTERED+1)) else - echo "[!] Failed: Api2::AddonDomain::addaddon" + echo "[!] Failed: Whostmgr::Domain::park" fi -# cPanel addon domain delete (post) if "$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_delete.sh" \ - --category Api2 --event AddonDomain::deladdondomain --stage post 2>/dev/null; then - echo "[+] Registered: Api2::AddonDomain::deladdondomain (post)" + --category Whostmgr --event Domain::unpark --stage pre 2>/dev/null; then + echo "[+] Registered: Whostmgr::Domain::unpark (pre)" REGISTERED=$((REGISTERED+1)) else - echo "[!] Failed: Api2::AddonDomain::deladdondomain" + echo "[!] Failed: Whostmgr::Domain::unpark" fi +# older cPanel: the Api2 addon events, harmless where they never fire +"$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_create.sh" \ + --category Api2 --event AddonDomain::addaddondomain --stage post &>/dev/null \ + && echo "[+] Registered: Api2::AddonDomain::addaddondomain (post)" +"$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_delete.sh" \ + --category Api2 --event AddonDomain::deladdondomain --stage post &>/dev/null \ + && echo "[+] Registered: Api2::AddonDomain::deladdondomain (post)" + echo "[+] Registered $REGISTERED hooks" # DNS backend detection From df04e8dcceecbe546dfd7b608ec172584d3fde87 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 12:23:01 +0200 Subject: [PATCH 7/9] Say when an account's domains cannot be read The lookup failed twice in silence: except Exception: pass inside the parser and 2>/dev/null around it. An account whose userdata cannot be read would disappear from the panel with every one of its zones left on the secondary and nothing in the log to say so. With a user in the payload and no domains found, the hook now logs the account name and the path to check, and queues nothing. Mutation: dropping that line fails the test. --- .../common/tests/test_cpanel_hook_payload.sh | 14 +++++++++++++ hosting-panels/cpanel/domain_delete.sh | 20 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/hosting-panels/common/tests/test_cpanel_hook_payload.sh b/hosting-panels/common/tests/test_cpanel_hook_payload.sh index be9110b..1fde2f7 100755 --- a/hosting-panels/common/tests/test_cpanel_hook_payload.sh +++ b/hosting-panels/common/tests/test_cpanel_hook_payload.sh @@ -72,6 +72,20 @@ PARK='{"context":{"category":"Whostmgr","event":"Domain::park","stage":"post"}," [ "$(extract "$DEL" "$PARK")" = "addon.example.com" ] \ && ok "delete reads new_domain, not the target" || fail "delete got '$(extract "$DEL" "$PARK")'" +echo "== an account with no readable userdata is reported, not swallowed" +TMPLOG="$(mktemp -d)" +cat > "$TMPLOG/conf" <<'CONF' +[seconddns] +api_url = http://127.0.0.1:1 +api_key = test-key +master_ip = 192.0.2.10 +CONF +SECONDDNS_CONFIG="$TMPLOG/conf" SECONDDNS_LOG="$TMPLOG/log" SECONDDNS_CPANEL_ROOT="$TMPLOG/empty" \ + bash "$DEL" <<< '{"data":{"user":"nosuchuser","killdns":1}}' >/dev/null 2>&1 +grep -q "nosuchuser" "$TMPLOG/log" 2>/dev/null \ + && ok "names the account in the log" || fail "silent: '$(cat "$TMPLOG/log" 2>/dev/null)'" +rm -rf "$TMPLOG" + echo echo "passed=$PASS failed=$FAIL" [ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/domain_delete.sh b/hosting-panels/cpanel/domain_delete.sh index 9304010..99c0af9 100755 --- a/hosting-panels/cpanel/domain_delete.sh +++ b/hosting-panels/cpanel/domain_delete.sh @@ -6,8 +6,8 @@ # Triggered before a cPanel account or addon domain is removed # Input: JSON on stdin — field: domain -CONFIG="/etc/seconddns.conf" -LOG="/var/log/seconddns.log" +CONFIG="${SECONDDNS_CONFIG:-/etc/seconddns.conf}" +LOG="${SECONDDNS_LOG:-/var/log/seconddns.log}" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"; } @@ -69,7 +69,21 @@ except Exception: pass " 2>/dev/null) -[ -z "$ZONE_NAMES" ] && exit 0 +if [ -z "$ZONE_NAMES" ]; then + # an account whose userdata cannot be read would otherwise leave every one + # of its zones on the secondary, silently + HOOK_USER=$(echo "$STDIN_DATA" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + src = d.get('data') if isinstance(d.get('data'), dict) else d + print(src.get('user') or '') +except Exception: + pass +" 2>/dev/null) + [ -n "$HOOK_USER" ] && log "[!] No domains found for account '$HOOK_USER' — its zones stay on the secondary; check /var/cpanel/userdata/$HOOK_USER/main" + exit 0 +fi DOMAIN_LIB="/usr/local/bin/seconddns-domain" [ -r "$DOMAIN_LIB" ] || { log "[!] $DOMAIN_LIB missing, cannot validate zone name"; exit 0; } From 210db1cca53ba88dc2074492cc2c90b8c861d79c Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 13:04:24 +0200 Subject: [PATCH 8/9] Handle a renamed main domain on cPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing an account's main domain in WHM removed the old zone from the panel and created a new one, and SecondDNS heard about neither: the old zone stayed on the secondary for a domain that no longer exists, the new one was never registered. Same defect as DirectAdmin #78, filed as #87 and left until a cPanel server existed to see it on. WHM fires Whostmgr::Accounts::Modify for this, and a probe on both stages showed the payload carries only the new name and the user: pre domain=my999domain.com user=my123domain post domain=my999domain.com user=my123domain The old name is nowhere in it — which is why cPanel's own ChangeDomains framework captures it at stage pre. The hook does the same: at pre the account on disk is still the old one, so the old main domain comes from userdata and the new one from the payload. WHM fires this event for every account change — quota, package, contact — so the hook queues nothing unless the two names differ. Live: my999domain.com -> my777domain.com, old zone deleted, new one delivered and answering on the secondary within seconds. tests/test_cpanel_rename.sh: both operations and their order, an unchanged domain queueing nothing, a quota-only change carrying no domain, an unknown account, an IDN new name in punycode, and an unusable new name that still removes the old zone and logs the refusal. Mutations: dropping the old-vs-new comparison fails 1, skipping the removal of the old zone fails 5. --- .github/workflows/tests.yml | 3 + .../common/tests/test_cpanel_rename.sh | 68 ++++++++++++++ hosting-panels/cpanel/domain_rename.sh | 93 +++++++++++++++++++ hosting-panels/cpanel/install.sh | 13 ++- hosting-panels/cpanel/uninstall.sh | 2 +- 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100755 hosting-panels/common/tests/test_cpanel_rename.sh create mode 100755 hosting-panels/cpanel/domain_rename.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bf6abe..c31044c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,9 @@ jobs: - name: test_cpanel_hook_payload.sh run: bash hosting-panels/common/tests/test_cpanel_hook_payload.sh + - name: test_cpanel_rename.sh + run: bash hosting-panels/common/tests/test_cpanel_rename.sh + - name: test_cyberpanel_db.sh run: bash hosting-panels/common/tests/test_cyberpanel_db.sh diff --git a/hosting-panels/common/tests/test_cpanel_rename.sh b/hosting-panels/common/tests/test_cpanel_rename.sh new file mode 100755 index 0000000..ef01c67 --- /dev/null +++ b/hosting-panels/common/tests/test_cpanel_rename.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# The cPanel rename hook: WHM sends the new main domain and keeps the old one +# on disk, so the pair has to be assembled from both. +# Run: bash hosting-panels/common/tests/test_cpanel_rename.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +HOOK="$HERE/../../cpanel/domain_rename.sh" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +command -v idn2 >/dev/null || { echo "idn2 required"; exit 1; } +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/userdata/acct" +printf -- '---\nmain_domain: old.example.com\naddon_domains: {}\nparked_domains: []\n' > "$TMP/userdata/acct/main" +cat > "$TMP/conf" <> "$CALLS"\n' > "$TMP/queue"; chmod +x "$TMP/queue" + +run() { : > "$TMP/calls"; : > "$TMP/log" + printf '%s' "$1" | env SECONDDNS_CONFIG="$TMP/conf" SECONDDNS_LOG="$TMP/log" \ + SECONDDNS_CPANEL_ROOT="$TMP" SECONDDNS_QUEUE_BIN="$TMP/queue" CALLS="$TMP/calls" \ + SECONDDNS_DOMAIN_BIN="$HERE/../seconddns-domain" \ + bash "$HOOK" >/dev/null 2>&1; } + +echo "== the main domain changes" +run '{"context":{"event":"Accounts::Modify","stage":"pre"},"data":{"user":"acct","domain":"new.example.com"}}' +n=$(wc -l < "$TMP/calls" | tr -d ' ') +[ "$n" -eq 2 ] && ok "two operations queued" || fail "queued $n, want 2: $(cat "$TMP/calls")" +[ "$(sed -n 1p "$TMP/calls")" = "enqueue delete old.example.com 192.0.2.10" ] \ + && ok "the old name goes first" || fail "first was '$(sed -n 1p "$TMP/calls")'" +[ "$(sed -n 2p "$TMP/calls")" = "enqueue create new.example.com 192.0.2.10" ] \ + && ok "the new name follows" || fail "second was '$(sed -n 2p "$TMP/calls")'" +grep -q "Zone renamed: old.example.com -> new.example.com" "$TMP/log" \ + && ok "rename logged" || fail "not logged: $(cat "$TMP/log")" + +echo "== the same domain: WHM fires this on every account change" +run '{"data":{"user":"acct","domain":"old.example.com"}}' +[ ! -s "$TMP/calls" ] && ok "an unchanged domain queues nothing" || fail "queued: $(cat "$TMP/calls")" + +echo "== a quota change carries no domain" +run '{"data":{"user":"acct","QUOTA":"500"}}' +[ ! -s "$TMP/calls" ] && ok "no domain, nothing queued" || fail "queued: $(cat "$TMP/calls")" + +echo "== an unknown account" +run '{"data":{"user":"nosuch","domain":"new.example.com"}}' +[ ! -s "$TMP/calls" ] && ok "unknown account queues nothing" || fail "queued: $(cat "$TMP/calls")" + +echo "== an IDN new name is canonicalised" +run '{"data":{"user":"acct","domain":"ПРИКЛАД.УКР"}}' +grep -q "enqueue create xn--80aikifvh.xn--j1amh 192.0.2.10" "$TMP/calls" \ + && ok "queued in punycode" || fail "not punycode: $(cat "$TMP/calls")" +grep -q "enqueue delete old.example.com" "$TMP/calls" \ + && ok "old name still removed" || fail "old name missing" + +echo "== an unusable new name still removes the old one" +run '{"data":{"user":"acct","domain":"not a domain"}}' +grep -q "enqueue delete old.example.com" "$TMP/calls" && ok "old name removed" || fail "old name missing" +grep -q "enqueue create" "$TMP/calls" && fail "queued a create for an unusable name" || ok "no create queued" +grep -qi "refused" "$TMP/log" && ok "refusal logged" || fail "refusal not logged" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/domain_rename.sh b/hosting-panels/cpanel/domain_rename.sh new file mode 100755 index 0000000..91b639a --- /dev/null +++ b/hosting-panels/cpanel/domain_rename.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Copyright © 2025-2026 SecondDNS +# Licensed under GNU General Public License v3.0 or SecondDNS Commercial License +# See LICENSE (GPLv3) or LICENSE.COMMERCIAL (commercial) for details +# SecondDNS integration — cPanel/WHM hook +# Triggered before an account is modified: WHM sends the new main domain, and +# the old one is still on disk at this stage. + +CONFIG="${SECONDDNS_CONFIG:-/etc/seconddns.conf}" +LOG="${SECONDDNS_LOG:-/var/log/seconddns.log}" + +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"; } + +[ -f "$CONFIG" ] || exit 0 + +API_URL=$(grep "^api_url" "$CONFIG" | sed 's/^api_url[[:space:]]*=[[:space:]]*//') +API_KEY=$(grep "^api_key" "$CONFIG" | sed 's/^api_key[[:space:]]*=[[:space:]]*//') +MASTER_IP=$(grep "^master_ip" "$CONFIG" | sed 's/^master_ip[[:space:]]*=[[:space:]]*//') + +[ -z "$API_URL" ] || [ -z "$API_KEY" ] || [ -z "$MASTER_IP" ] && exit 0 + +STDIN_DATA=$(cat) +PAIR=$(echo "$STDIN_DATA" | python3 -c " +import sys, json, os +def main_domain(user): + root = os.environ.get('SECONDDNS_CPANEL_ROOT', '/var/cpanel') + try: + with open('%s/userdata/%s/main' % (root, user)) as fh: + for line in fh: + if line.startswith('main_domain:'): + return line.split(':', 1)[1].strip() + except Exception: + pass + try: + with open('%s/users/%s' % (root, user)) as fh: + for line in fh: + if line.startswith('DNS='): + return line.split('=', 1)[1].strip() + except Exception: + pass + return '' + +try: + d = json.load(sys.stdin) + src = d.get('data') if isinstance(d.get('data'), dict) else d + new = (src.get('domain') or '').strip() + user = (src.get('user') or '').strip() + old = main_domain(user) if user else '' + if new and old and new != old: + print(old) + print(new) +except Exception: + pass +" 2>/dev/null) + +# nothing to do when the main domain is unchanged: WHM fires this hook for +# every account change, quota and package included +[ -z "$PAIR" ] && exit 0 + +OLD_NAME=$(echo "$PAIR" | sed -n 1p) +NEW_NAME=$(echo "$PAIR" | sed -n 2p) + +DOMAIN_LIB="${SECONDDNS_DOMAIN_BIN:-/usr/local/bin/seconddns-domain}" +[ -r "$DOMAIN_LIB" ] || { log "[!] $DOMAIN_LIB missing, cannot validate zone name"; exit 0; } +SECONDDNS_DOMAIN_LIB=1 . "$DOMAIN_LIB" +QUEUE="${SECONDDNS_QUEUE_BIN:-/usr/local/bin/seconddns-queue}" + +if canonical_domain "$OLD_NAME"; then + OLD_CANON="$DOMAIN" + if "$QUEUE" enqueue delete "$OLD_CANON" "$MASTER_IP"; then + log "[>] Zone $OLD_CANON removal queued for SecondDNS (renamed)" + else + log "[!] Zone $OLD_CANON removal NOT queued (seconddns-queue failed)" + fi +else + log "[!] Old zone '$OLD_NAME' refused: $DOMAIN_ERROR (cpanel rename hook)" +fi + +if ! canonical_domain "$NEW_NAME"; then + log "[!] Zone '$NEW_NAME' refused: $DOMAIN_ERROR (cpanel rename hook)" + exit 0 +fi +NEW_CANON="$DOMAIN" +RAW_NOTE=""; [ "$NEW_NAME" != "$NEW_CANON" ] && RAW_NOTE=" (received as '$NEW_NAME')" +log "Zone renamed: $OLD_NAME -> $NEW_CANON (cpanel hook)$RAW_NOTE" + +if "$QUEUE" enqueue create "$NEW_CANON" "$MASTER_IP"; then + log "[>] Zone $NEW_CANON queued for SecondDNS" +else + log "[!] Zone $NEW_CANON NOT queued (seconddns-queue failed)" +fi + +exit 0 diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index c39cc9b..04aee4b 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -215,7 +215,7 @@ chmod 664 "$LOG_FILE" echo "[+] Log file: $LOG_FILE" # Install hook scripts -for script in domain_create.sh domain_delete.sh; do +for script in domain_create.sh domain_delete.sh domain_rename.sh; do curl -sf --max-time 10 -o "$SCRIPT_DIR/seconddns-cpanel-${script}" "$REPO_URL/$script?t=$(date +%s)" chmod +x "$SCRIPT_DIR/seconddns-cpanel-${script}" echo "[+] Installed: $SCRIPT_DIR/seconddns-cpanel-${script}" @@ -272,6 +272,16 @@ else echo "[!] Failed: Whostmgr::Accounts::Remove" fi +# Renaming an account's main domain: WHM sends the new name and keeps the old +# one on disk until this hook returns, so it runs at stage pre. +if "$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_rename.sh" \ + --category Whostmgr --event Accounts::Modify --stage pre 2>/dev/null; then + echo "[+] Registered: Whostmgr::Accounts::Modify (pre)" + REGISTERED=$((REGISTERED+1)) +else + echo "[!] Failed: Whostmgr::Accounts::Modify" +fi + # Addon, parked and alias domains all go through park/unpark; the Api2 # AddonDomain events are not fired by current cPanel and are kept as a fallback. if "$HOOKS_BIN" add script "$SCRIPT_DIR/seconddns-cpanel-domain_create.sh" \ @@ -536,6 +546,7 @@ echo "=== Installation complete ===" echo "" echo " Config: $CONFIG_FILE" echo " Scripts: $SCRIPT_DIR/seconddns-cpanel-domain_create.sh" +echo " $SCRIPT_DIR/seconddns-cpanel-domain_rename.sh" echo " $SCRIPT_DIR/seconddns-cpanel-domain_delete.sh" echo " Logs: tail -f $LOG_FILE" echo "" diff --git a/hosting-panels/cpanel/uninstall.sh b/hosting-panels/cpanel/uninstall.sh index c809652..cb3c6f4 100755 --- a/hosting-panels/cpanel/uninstall.sh +++ b/hosting-panels/cpanel/uninstall.sh @@ -49,7 +49,7 @@ else fi # Remove scripts -for script in domain_create.sh domain_delete.sh; do +for script in domain_create.sh domain_delete.sh domain_rename.sh; do target="$SCRIPT_DIR/seconddns-cpanel-${script}" if [ -f "$target" ]; then rm -f "$target" From b3b5ba728d112b0aa5077eb2ac539e32a2c5cb27 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 6 Sep 2026 13:08:05 +0200 Subject: [PATCH 9/9] cPanel is no longer beta: describe the events it actually hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README said two scripts and four events, two of which cPanel does not fire. It is three scripts and five events now, and the beta banner goes: the integration has been through the full round on a live cPanel 136 server — account create and remove, addon, alias, IDN, rename, and the queue under an unreachable API. Also noted: WHM refuses Unicode in the domain field, so an IDN has to be entered in punycode there — unlike DirectAdmin, which accepts either. --- README.md | 2 +- hosting-panels/cpanel/README.md | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3ef39bf..a349fac 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ All integrations use the same pattern: catch the panel event, call the SecondDNS | Panel | Mechanism | Tested on | |:------|:----------|:----------| -| [cPanel/WHM](hosting-panels/cpanel/) **(beta)** | Standardized Hooks via `manage_hooks` (4 events) | cPanel/WHM v82+ | +| [cPanel/WHM](hosting-panels/cpanel/) | Standardized Hooks via `manage_hooks` (5 events, incl. rename + addon/alias) | cPanel/WHM 136 | | [CyberPanel](hosting-panels/cyberpanel/) | Django signals (6: website, domain and zone, create and delete) | CyberPanel 2.4.5 | | [DirectAdmin](hosting-panels/directadmin/) | Custom hooks (`dns_create_post`, `dns_delete_post`, `domain_change_post`) | DirectAdmin 1.699, 1.709 | | [Plesk](hosting-panels/plesk/) | Event Manager (12 events, incl. rename + aliases) | Plesk Obsidian 18.0.77.2 | diff --git a/hosting-panels/cpanel/README.md b/hosting-panels/cpanel/README.md index 1b8cabb..6e42219 100644 --- a/hosting-panels/cpanel/README.md +++ b/hosting-panels/cpanel/README.md @@ -1,19 +1,25 @@ # SecondDNS — cPanel/WHM Hosting Panel Integration -> **Status: Beta — actively tested. Use in production at your own risk. Feedback welcome.** - Automatic secondary DNS for cPanel/WHM servers. Uses the cPanel Standardized Hooks system to sync domain creation and deletion to SecondDNS via API + AXFR. ## How It Works -Two shell scripts are registered as WHM hooks for 4 events: +Three shell scripts are registered as WHM hooks: + +1. **Account created** (`Whostmgr::Accounts::Create`) — registers the zone +2. **Account removed** (`Whostmgr::Accounts::Remove`, stage `pre`) — removes every zone of the account: main, addon and alias. The event carries only the username, so the domains are read from the account's userdata while it is still on disk +3. **Domain added** (`Whostmgr::Domain::park`) — addon, alias and parked domains all arrive through this one +4. **Domain removed** (`Whostmgr::Domain::unpark`) +5. **Main domain renamed** (`Whostmgr::Accounts::Modify`, stage `pre`) — removes the old zone and registers the new one. WHM sends only the new name, so the old one is read from userdata before the change lands + +The `Api2::AddonDomain::*` events are registered too, as a fallback for older cPanel that still fires them. + +After zone registration, SecondDNS pulls the full zone via AXFR. Subsequent changes propagate via NOTIFY. -1. **Account created** (`Whostmgr::Accounts::Create`) — calls SecondDNS API to register the zone -2. **Account removed** (`Whostmgr::Accounts::Remove`) — removes the zone from SecondDNS -3. **Addon domain added** (`Api2::AddonDomain::addaddon`) — registers the addon domain zone -4. **Addon domain deleted** (`Api2::AddonDomain::deladdondomain`) — removes the addon domain zone +### IDN domains -After zone registration, SecondDNS pulls the full zone via AXFR. Subsequent changes propagate via BIND NOTIFY. +WHM does not accept Unicode in the domain field: enter an IDN in punycode +(`xn--…`). The integration passes it through unchanged. ## Requirements