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 @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
68 changes: 68 additions & 0 deletions hosting-panels/common/tests/test_cpanel_rename.sh
Original file line number Diff line number Diff line change
@@ -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" <<CONF
[seconddns]
api_url = http://127.0.0.1:1
api_key = test-key
master_ip = 192.0.2.10
CONF
printf '#!/bin/bash\necho "$*" >> "$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 ]
22 changes: 14 additions & 8 deletions hosting-panels/cpanel/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
93 changes: 93 additions & 0 deletions hosting-panels/cpanel/domain_rename.sh
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion hosting-panels/cpanel/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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 ""
Expand Down
2 changes: 1 addition & 1 deletion hosting-panels/cpanel/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down