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 1/2] 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 2/2] 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