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