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..1fde2f7 --- /dev/null +++ b/hosting-panels/common/tests/test_cpanel_hook_payload.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# 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)" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +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") + [ -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" + [ "$(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" + [ -z "$(extract "$f" 'not json')" ] && ok "malformed input yields nothing" || fail "malformed input" +done + +echo "== an addon domain arrives as newdomain" +[ "$(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 "== 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_create.sh b/hosting-panels/cpanel/domain_create.sh index d77c47e..689f5e7 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('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 b2993ba..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"; } @@ -20,24 +20,85 @@ 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) - print(d.get('domain') or '') + src = d.get('data') if isinstance(d.get('data'), dict) else d + 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 +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; } 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 @@ -45,22 +106,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