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