Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions hosting-panels/common/tests/test_cpanel_hook_payload.sh
Original file line number Diff line number Diff line change
@@ -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 ]
4 changes: 3 additions & 1 deletion hosting-panels/cpanel/domain_create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 70 additions & 11 deletions hosting-panels/cpanel/domain_delete.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"; }

Expand All @@ -20,47 +20,106 @@ 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/<user>/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
RAW_NOTE=""; [ "$RAW_NAME" != "$ZONE_NAME" ] && RAW_NOTE=" (received as '$RAW_NAME')"

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
24 changes: 16 additions & 8 deletions hosting-panels/cpanel/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down