diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b84c65b..c31044c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,21 @@ jobs: - name: test_ip_detection.sh run: bash hosting-panels/common/tests/test_ip_detection.sh + - name: test_cli.sh + run: bash hosting-panels/common/tests/test_cli.sh + + - name: test_hostname_filter.sh + run: bash hosting-panels/common/tests/test_hostname_filter.sh + + - 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_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/seconddns b/hosting-panels/common/seconddns new file mode 100755 index 0000000..837aa61 --- /dev/null +++ b/hosting-panels/common/seconddns @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# 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 +"""Manage this server's zones in SecondDNS. + + seconddns list zones in SecondDNS, with status and master + seconddns status queue state: pending / failed / oldest / last error + seconddns status one zone: known here, mastered by whom, last sync + seconddns add register a zone + seconddns remove remove a zone + seconddns sync register every panel zone SecondDNS does not have + seconddns sync --prune also remove zones this panel no longer serves + seconddns remove-all remove every zone mastered by this server + +Changes go through seconddns-queue, so they carry retry, FIFO order and the +delete-side ownership check. Deleting is never implicit: plain `sync` only +adds, `--prune` is refused when it would drop more than half of this server's +zones, and `remove-all` asks before doing anything. + +Exit 0 = done, 1 = something failed, 2 = refused. +""" +import argparse +import os +import signal +import subprocess +import sys + +# piping into head/grep must not end in a BrokenPipeError traceback +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from seconddns_common import ( # noqa: E402 + api, load_config, panel_zones, canonical, canonical_targets, enqueue, PanelError, QUEUE_BIN, +) + +UA = "SecondDNS-CLI/1.0" + + +def fetch_zones(cfg): + code, zones = api(cfg, "GET", "/api/zones", ua=UA) + if code != 200 or not isinstance(zones, list): + why = zones.get("error", "") if isinstance(zones, dict) else "" + sys.exit(f"GET /api/zones failed: HTTP {code} {why}") + return zones + + +def resolve(name): + """Panel input to the canonical zone name, or exit with the reason.""" + canonical_name, reason = canonical(name) + if reason: + sys.exit(f"{name}: {reason}") + return canonical_name + + +def cmd_list(cfg, _a): + zones = fetch_zones(cfg) + if not zones: + print("No zones in SecondDNS for this account.") + return 0 + print(f"{'Name':<40} {'Status':<10} {'Master':<18} Last sync") + print("-" * 90) + for z in sorted(zones, key=lambda z: z["name"]): + print(f"{z['name']:<40} {z.get('status', '?'):<10} {z.get('masterIp', '?'):<18} {z.get('lastSync') or 'never'}") + return 0 + + +def cmd_status(cfg, a): + if a.domain: + name = resolve(a.domain) + zone = next((z for z in fetch_zones(cfg) if z["name"] == name), None) + if zone is None: + print(f"{name}: not in SecondDNS") + return 1 + mine = zone.get("masterIp") == cfg["master_ip"] + print(f"{name}: {zone.get('status', '?')}, mastered by {zone.get('masterIp', '?')}" + f"{'' if mine else ' (not this server)'}, last sync {zone.get('lastSync') or 'never'}") + return 0 + # no domain: the queue is what an operator is asking about + return subprocess.run([QUEUE_BIN, "status"]).returncode + + +def cmd_add(cfg, a): + name = resolve(a.domain) + if enqueue("create", name, cfg["master_ip"]): + print(f"queued: create {name}") + return 0 + print(f"could not queue: create {name}", file=sys.stderr) + return 1 + + +def cmd_remove(cfg, a): + name = resolve(a.domain) + if enqueue("delete", name, cfg["master_ip"]): + print(f"queued: delete {name}") + return 0 + print(f"could not queue: delete {name}", file=sys.stderr) + return 1 + + +def cmd_sync(cfg, a): + try: + raw = panel_zones() + except PanelError as e: + sys.exit(str(e)) + panel, skipped = canonical_targets(raw) + if raw and not panel: + sys.exit("the panel's zone names are all unusable; refusing to continue") + for name, why in skipped: + print(f"[!] skipped {name}: {why}", file=sys.stderr) + + zones = fetch_zones(cfg) + have = {z["name"] for z in zones} + mine = [z for z in zones if z.get("masterIp") == cfg["master_ip"]] + + missing = [n for n in panel if n not in have] + failed = 0 + for n in missing: + if enqueue("create", n, cfg["master_ip"]): + print(f"queued: create {n}") + else: + print(f"could not queue: create {n}", file=sys.stderr); failed += 1 + + pruned = [] + if a.prune: + panel_set = set(panel) + stale = [z for z in mine if z["name"] not in panel_set] + # an unreadable or half-read panel looks exactly like "everything is gone" + if stale and len(stale) * 2 > len(mine): + msg = (f"--prune would remove {len(stale)} of {len(mine)} zone(s) mastered by this server; " + "that looks like a wrong zone list, not a cleanup") + if not a.force: + print(msg + ". Check `seconddns list`; --force overrides.", file=sys.stderr) + return 2 + print("[!] " + msg + " — proceeding on --force", file=sys.stderr) + for z in stale: + if enqueue("delete", z["name"], cfg["master_ip"]): + print(f"queued: delete {z['name']}"); pruned.append(z["name"]) + else: + print(f"could not queue: delete {z['name']}", file=sys.stderr); failed += 1 + + print(f"\n{len(missing)} to add, {len(pruned)} to remove, {failed} failed") + return 1 if failed else 0 + + +def cmd_remove_all(cfg, a): + mine = [z for z in fetch_zones(cfg) if z.get("masterIp") == cfg["master_ip"]] + if not mine: + print("No zones mastered by this server.") + return 0 + print(f"This removes {len(mine)} zone(s) mastered by {cfg['master_ip']} from SecondDNS:") + for z in sorted(mine, key=lambda z: z["name"])[:10]: + print(f" {z['name']}") + if len(mine) > 10: + print(f" ... and {len(mine) - 10} more") + print("Your DNS server keeps them; only the secondary copies go.") + if not a.yes: + if not sys.stdin.isatty(): + print("Refusing without a terminal to confirm on; pass --yes if you mean it.", file=sys.stderr) + return 2 + if input("Type the master IP to confirm: ").strip() != cfg["master_ip"]: + print("Not confirmed; nothing done.", file=sys.stderr) + return 2 + failed = 0 + for z in mine: + if enqueue("delete", z["name"], cfg["master_ip"]): + print(f"queued: delete {z['name']}") + else: + print(f"could not queue: delete {z['name']}", file=sys.stderr); failed += 1 + return 1 if failed else 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.environ.get("SECONDDNS_CONF", "/etc/seconddns.conf")) + sub = ap.add_subparsers(dest="cmd") + + sub.add_parser("list") + p = sub.add_parser("status"); p.add_argument("domain", nargs="?") + p = sub.add_parser("add"); p.add_argument("domain") + p = sub.add_parser("remove"); p.add_argument("domain") + p = sub.add_parser("sync") + p.add_argument("--prune", action="store_true", help="also remove zones this panel no longer serves") + p.add_argument("--force", action="store_true", help="allow --prune to drop more than half of this server's zones") + p = sub.add_parser("remove-all"); p.add_argument("--yes", action="store_true", help="skip the confirmation") + + a = ap.parse_args() + if not a.cmd: + ap.print_help() + return 2 + + cfg = load_config(a.config) + if not cfg["master_ip"]: + sys.exit("master_ip missing in the config") + + return { + "list": cmd_list, "status": cmd_status, "add": cmd_add, "remove": cmd_remove, + "sync": cmd_sync, "remove-all": cmd_remove_all, + }[a.cmd](cfg, a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hosting-panels/common/seconddns_common.py b/hosting-panels/common/seconddns_common.py index 3974751..868c3c9 100644 --- a/hosting-panels/common/seconddns_common.py +++ b/hosting-panels/common/seconddns_common.py @@ -5,6 +5,7 @@ import configparser import json import os +import socket import subprocess import sys import urllib.error @@ -163,11 +164,28 @@ def panel_zones(): zones = [l.strip() for l in (out or "").splitlines() if l.strip()] if out is None: raise PanelError(f"could not read the zone list ({source}); refusing to continue") + # The server's own hostname zone belongs to the host, not the customer; the + # API refuses it, so every cPanel install left a failed op behind. + host = _server_hostname() + if host: + zones = [z for z in zones if z.lower().rstrip(".") != host] # A panel with no zones is a legal state — a fresh server, or one that was # emptied. Only a failed read is an error, and that is the branch above. return zones +def _server_hostname(): + """This machine's fully qualified name, lowercased, or '' if unknown.""" + name = os.environ.get("SECONDDNS_HOSTNAME") # tests + if name is None: + try: + name = socket.getfqdn() + except OSError: + return "" + name = (name or "").strip().lower().rstrip(".") + return name if "." in name else "" + + def zones_from_file(path): zones = [l.strip() for l in open(path) if l.strip() and not l.startswith("#")] if not zones: diff --git a/hosting-panels/common/tests/test_cli.sh b/hosting-panels/common/tests/test_cli.sh new file mode 100755 index 0000000..36c1d9c --- /dev/null +++ b/hosting-panels/common/tests/test_cli.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# The seconddns CLI: deleting is never implicit, and every change goes through +# the queue rather than straight to the API. +# Run: bash hosting-panels/common/tests/test_cli.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +CLI="$HERE/../seconddns" +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)"; PORT=$((20000 + RANDOM % 10000)) +trap 'kill $MOCK_PID 2>/dev/null; rm -rf "$TMP"' EXIT + +python3 - "$PORT" "$TMP" > /dev/null 2>&1 <<'PYEOF' & +import http.server, sys +port, tmp = int(sys.argv[1]), sys.argv[2] +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + b = open(tmp + '/zones.json', 'rb').read() + self.send_response(200); self.send_header('Content-Length', str(len(b))); self.end_headers() + self.wfile.write(b) + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1', port), H).serve_forever() +PYEOF +MOCK_PID=$! +disown $MOCK_PID 2>/dev/null || true +sleep 1 + +cat > "$TMP/seconddns.conf" <> "$CALLS"\n' > "$TMP/queue"; chmod +x "$TMP/queue" + +zones() { printf '%s' "$1" > "$TMP/zones.json"; } +cli() { : > "$TMP/calls" + env SECONDDNS_CONF="$TMP/seconddns.conf" SECONDDNS_QUEUE_BIN="$TMP/queue" \ + SECONDDNS_DOMAIN_BIN="$HERE/../seconddns-domain" \ + SECONDDNS_PANEL_ZONES_CMD="${PANEL:-true}" CALLS="$TMP/calls" \ + python3 "$CLI" "$@" > "$TMP/out" 2>&1; } + +MINE='{"name":"%s","masterIp":"192.0.2.10","status":"synced"}' +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com)]" + +echo "== add and remove go through the queue" +cli add new.example.com +grep -q "enqueue create new.example.com 192.0.2.10" "$TMP/calls" && ok "add queues a create" || fail "add: $(cat "$TMP/calls")" +cli remove a.example.com +grep -q "enqueue delete a.example.com 192.0.2.10" "$TMP/calls" && ok "remove queues a delete" || fail "remove: $(cat "$TMP/calls")" +cli add ПРИКЛАД.УКР +grep -q "enqueue create xn--80aikifvh.xn--j1amh" "$TMP/calls" && ok "an IDN is queued in punycode" || fail "IDN: $(cat "$TMP/calls")" +cli add "not a domain"; rc=$? +[ "$rc" -ne 0 ] && [ ! -s "$TMP/calls" ] && ok "an unusable name is refused, nothing queued" || fail "unusable name accepted" + +echo "== sync only adds" +PANEL="printf '%s\n' a.example.com b.example.com c.example.com" cli sync +grep -q "enqueue create c.example.com" "$TMP/calls" && ok "sync queues the missing zone" || fail "sync missing" +grep -q "enqueue delete" "$TMP/calls" && fail "plain sync queued a delete" || ok "plain sync deletes nothing" + +echo "== sync --prune removes, within the guard" +# exactly half is allowed, more than half is not — check both sides of the line +PANEL="printf '%s\n' a.example.com" cli sync --prune; rc=$? +[ "$rc" -eq 0 ] && ok "half of two is allowed" || fail "refused exactly half (rc=$rc)" +grep -q "enqueue delete b.example.com" "$TMP/calls" && ok "the stale one is queued" || fail "half: $(cat "$TMP/calls")" + +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com),$(printf "$MINE" c.example.com)]" +PANEL="printf '%s\n' a.example.com" cli sync --prune; rc=$? +[ "$rc" -eq 2 ] && ok "two of three is refused" || fail "prune proceeded (rc=$rc)" +grep -q "enqueue delete" "$TMP/calls" && fail "queued a delete while refusing" || ok "nothing queued while refusing" + +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com),$(printf "$MINE" c.example.com),$(printf "$MINE" d.example.com)]" +PANEL="printf '%s\n' a.example.com b.example.com c.example.com" cli sync --prune +grep -q "enqueue delete d.example.com" "$TMP/calls" && ok "prunes one of four" || fail "prune of one: $(cat "$TMP/calls")" + +PANEL="printf '%s\n' a.example.com" cli sync --prune --force +n=$(grep -c "enqueue delete" "$TMP/calls") +[ "$n" -eq 3 ] && ok "--force allows the bulk prune" || fail "--force queued $n deletes, want 3" + +echo "== a zone mastered elsewhere is never touched" +zones '[{"name":"a.example.com","masterIp":"192.0.2.10","status":"synced"},{"name":"other.example.com","masterIp":"203.0.113.9","status":"synced"}]' +PANEL="printf '%s\n' a.example.com" cli sync --prune +grep -q "other.example.com" "$TMP/calls" && fail "queued a zone mastered elsewhere" || ok "another server's zone left alone" + +echo "== remove-all asks" +zones "[$(printf "$MINE" a.example.com),$(printf "$MINE" b.example.com)]" +cli remove-all < /dev/null; rc=$? +[ "$rc" -eq 2 ] && ok "refuses without a confirmation" || fail "remove-all proceeded unconfirmed (rc=$rc)" +[ ! -s "$TMP/calls" ] && ok "nothing queued unconfirmed" || fail "queued: $(cat "$TMP/calls")" +cli remove-all --yes +n=$(grep -c "enqueue delete" "$TMP/calls") +[ "$n" -eq 2 ] && ok "--yes removes this server's zones" || fail "--yes queued $n, want 2" + +echo "== status and list read, never write" +cli list; grep -q "a.example.com" "$TMP/out" && ok "list shows the zones" || fail "list: $(cat "$TMP/out")" +[ ! -s "$TMP/calls" ] && ok "list queues nothing" || fail "list queued something" +cli status a.example.com; grep -q "mastered by 192.0.2.10" "$TMP/out" && ok "status reports one zone" || fail "status: $(cat "$TMP/out")" +cli status unknown.example.com; rc=$? +[ "$rc" -eq 1 ] && ok "an unknown zone exits 1" || fail "unknown zone rc=$rc" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] 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/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/common/tests/test_hostname_filter.sh b/hosting-panels/common/tests/test_hostname_filter.sh new file mode 100755 index 0000000..7668da5 --- /dev/null +++ b/hosting-panels/common/tests/test_hostname_filter.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# The server's own hostname is not a customer zone: cPanel lists it, the API +# refuses it, and every install left a failed op behind. +# Run: bash hosting-panels/common/tests/test_hostname_filter.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"; } + +zones() { # $1 = hostname, $2 = panel zone list + env SECONDDNS_HOSTNAME="$1" SECONDDNS_PANEL_ZONES_CMD="printf '%s\n' $2" \ + PYTHONPATH="$HERE/.." python3 -c " +from seconddns_common import panel_zones +print(' '.join(panel_zones())) +" 2>&1 +} + +echo "== the hostname zone is dropped" +out=$(zones "server1.seconddns.com" "a.example.com server1.seconddns.com b.example.com") +[ "$out" = "a.example.com b.example.com" ] && ok "hostname removed, order kept" || fail "got '$out'" + +echo "== matching is case- and dot-insensitive" +out=$(zones "server1.seconddns.com" "SERVER1.SecondDNS.com a.example.com") +[ "$out" = "a.example.com" ] && ok "uppercase form removed" || fail "got '$out'" +out=$(zones "server1.seconddns.com" "server1.seconddns.com. a.example.com") +[ "$out" = "a.example.com" ] && ok "trailing dot removed" || fail "got '$out'" + +echo "== only the exact name goes" +out=$(zones "server1.seconddns.com" "seconddns.com sub.server1.seconddns.com a.example.com") +[ "$out" = "seconddns.com sub.server1.seconddns.com a.example.com" ] \ + && ok "parent and child zones kept" || fail "got '$out'" + +echo "== a bare hostname is not a zone name" +out=$(zones "localhost" "localhost a.example.com") +[ "$out" = "localhost a.example.com" ] && ok "no dot, nothing dropped" || fail "got '$out'" + +echo "== an empty panel stays empty, not an error" +out=$(zones "server1.seconddns.com" "") +[ -z "$out" ] && ok "empty list survives the filter" || fail "got '$out'" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/common/tests/test_pdns_restart.sh b/hosting-panels/common/tests/test_pdns_restart.sh new file mode 100755 index 0000000..b353481 --- /dev/null +++ b/hosting-panels/common/tests/test_pdns_restart.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# PowerDNS rereads zones on a reload but not allow-axfr-ips, so a reload after +# editing pdns.conf leaves AXFR refused and every zone stuck pending. +# Run: bash hosting-panels/common/tests/test_pdns_restart.sh +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +PANELS="cpanel cyberpanel directadmin plesk" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +checked=0 +for panel in $PANELS; do + src="$HERE/../../$panel/install.sh" + [ -f "$src" ] || { echo "missing $src"; exit 1; } + grep -q "pdns" "$src" || continue + checked=$((checked+1)) + echo "== $panel" + + if grep -qE "systemctl reload pdns|pdns_control reload|service pdns reload" "$src"; then + fail "reloads PowerDNS after editing pdns.conf" + else + ok "no PowerDNS reload" + fi + + # editing the config at all obliges a restart + if grep -q 'allow-axfr-ips' "$src"; then + grep -qE "(systemctl|service) .*pdns.* restart|systemctl restart pdns" "$src" \ + && ok "restarts PowerDNS" || fail "edits allow-axfr-ips without restarting" + # a restart that does not come back must not be reported as success: + # the config is restored and the operator told, not a cheerful "[+]" + grep -q 'did not come back' "$src" \ + && ok "reports a PowerDNS that stayed down" || fail "a dead PowerDNS would be reported as success" + grep -qE 'cp "[^"]*(BAK|bak)[^"]*" "\$PDNS_CONF"' "$src" \ + && ok "restores the config on a failed restart" || fail "no rollback on a failed restart" + fi +done + +# a lower bound: a rename that stops matching the installers fails here +[ "$checked" -ge 3 ] && ok "checked $checked installers" || fail "only $checked installers matched" + +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_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/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 96f7b08..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}" @@ -225,6 +225,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -233,7 +234,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") @@ -271,24 +272,42 @@ else echo "[!] Failed: Whostmgr::Accounts::Remove" fi -# cPanel addon domain create (post) +# 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" \ - --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 @@ -350,7 +369,8 @@ else if [ ! -f "$PDNS_CONF" ]; then echo "[!] pdns.conf not found at $PDNS_CONF — configure AXFR manually" else - cp "$PDNS_CONF" "${PDNS_CONF}.bak.$(date +%s)" + PDNS_BAK="${PDNS_CONF}.bak.$(date +%s)" + cp "$PDNS_CONF" "$PDNS_BAK" # primary / master mode (name differs by pdns version) if grep -q "^master=" "$PDNS_CONF" 2>/dev/null; then @@ -400,13 +420,19 @@ else echo "[+] PowerDNS: also-notify=$SECONDARY_IP" fi - # Reload - if systemctl reload pdns &>/dev/null 2>&1; then - echo "[+] PowerDNS reloaded" - elif pdns_control reload &>/dev/null 2>&1; then - echo "[+] PowerDNS reloaded via pdns_control" + # A reload rereads the zones, not the settings above: allow-axfr-ips + # only takes effect on a restart, and until then AXFR is refused. + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + sleep 2 + if systemctl is-active --quiet pdns 2>/dev/null || pgrep -x pdns_server >/dev/null; then + echo "[+] PowerDNS configured and restarted" else - echo "[!] Restart PowerDNS manually: systemctl restart pdns" + # Saying "OK" while the DNS server is down is worse than + # failing: restore what was there and let a human look. + [ -n "${PDNS_BAK:-}" ] && cp "$PDNS_BAK" "$PDNS_CONF" + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + echo "[!] PowerDNS did not come back — configuration restored from the backup" + echo "[!] Check: journalctl -u pdns -n 20" fi fi @@ -502,39 +528,6 @@ if [ -n "$API_NS" ]; then fi fi -# Initial sync -echo "" -if confirm "Sync existing cPanel accounts to secondary DNS now?"; then - echo "[*] Syncing accounts..." - added=0 - failed=0 - - if [ -d /var/cpanel/users ]; then - for user_file in /var/cpanel/users/*; do - [ -f "$user_file" ] || continue - sdomain=$(grep "^DNS=" "$user_file" 2>/dev/null | cut -d= -f2) - [ -z "$sdomain" ] && continue - - response=$(curl -sf --max-time 15 \ - -X POST \ - -H "X-API-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -H "User-Agent: SecondDNS-cPanel/1.0" \ - -d "{\"name\":\"$sdomain\",\"masterIp\":\"$MASTER_IP\"}" \ - "$API_URL/api/zones" 2>/dev/null) - if [ $? -eq 0 ]; then - echo " [+] $sdomain" - added=$((added+1)) - else - failed=$((failed+1)) - fi - done - else - echo "[!] /var/cpanel/users not found — skipping sync" - fi - - echo "[+] Synced: $added domains, failed: $failed" -fi echo "" # reconcile already knows where each panel keeps its zone list; --add-missing @@ -553,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 214760f..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" @@ -105,7 +105,7 @@ echo " Verify: $HOOKS_BIN list" systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/cyberpanel/install.sh b/hosting-panels/cyberpanel/install.sh index cefd42f..7ebb7e8 100755 --- a/hosting-panels/cyberpanel/install.sh +++ b/hosting-panels/cyberpanel/install.sh @@ -192,12 +192,6 @@ if [ -z "$MASTER_IP" ]; then fi fi -# Install CLI -echo "" -cp "$CYBER_SRC/seconddns.py" "$INSTALL_DIR/seconddns" -chmod +x "$INSTALL_DIR/seconddns" -echo "[+] Installed CLI to $INSTALL_DIR/seconddns" - # Create or update config # A bad master IP installs cleanly and then fails every zone with an opaque # HTTP 400 from the API, so refuse here instead. @@ -232,6 +226,8 @@ echo "[+] Log file: $LOG_FILE" # Install CyberPanel plugin if [ -d "$CYBERPANEL_DIR" ]; then cp "$CYBER_SRC/seconddns.py" "$PLUGIN_FILE" + # seconddns-signals.service runs it directly for ensure-signals + chmod +x "$PLUGIN_FILE" echo "[+] Installed plugin to $PLUGIN_FILE" # Clean and register signals @@ -273,6 +269,9 @@ with open(p, 'w') as f: f.write(c) # --- Offline operation queue --- COMMON_SRC="$WORK_DIR/dns_integrations/hosting-panels/common" +# the shared CLI, same commands and guards as on every other panel; +# seconddns.py stays behind as the Django plugin only +cp "$COMMON_SRC/seconddns" /usr/local/bin/seconddns cp "$COMMON_SRC/seconddns-domain" /usr/local/bin/seconddns-domain cp "$COMMON_SRC/seconddns-owner" /usr/local/bin/seconddns-owner cp "$COMMON_SRC/seconddns-migrate-master" /usr/local/bin/seconddns-migrate-master @@ -281,7 +280,7 @@ cp "$COMMON_SRC/seconddns_common.py" /usr/local/bin/seconddns_common.py cp "$COMMON_SRC/seconddns-queue" /usr/local/bin/seconddns-queue cp "$COMMON_SRC/seconddns-queued" /usr/local/bin/seconddns-queued cp "$COMMON_SRC/seconddns-queued.service" /etc/systemd/system/seconddns-queued.service -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash "$COMMON_SRC/install-idn2.sh" mkdir -p /var/lib/seconddns bash "$COMMON_SRC/install-sqlite.sh" diff --git a/hosting-panels/cyberpanel/seconddns-signals.service b/hosting-panels/cyberpanel/seconddns-signals.service index 3086e01..94ae9d2 100644 --- a/hosting-panels/cyberpanel/seconddns-signals.service +++ b/hosting-panels/cyberpanel/seconddns-signals.service @@ -5,7 +5,7 @@ BindsTo=lscpd.service [Service] Type=oneshot -ExecStart=/usr/local/bin/seconddns ensure-signals +ExecStart=/usr/local/CyberCP/plogical/seconddns_plugin.py ensure-signals RemainAfterExit=no [Install] diff --git a/hosting-panels/cyberpanel/seconddns.py b/hosting-panels/cyberpanel/seconddns.py index 49a9858..ed3e1c6 100755 --- a/hosting-panels/cyberpanel/seconddns.py +++ b/hosting-panels/cyberpanel/seconddns.py @@ -7,13 +7,9 @@ Works in two modes: 1. Django signal plugin — hooks into CyberPanel's postWebsiteCreation/Deletion signals - 2. CLI — manual add/remove/sync/list commands + 2. ensure-signals — re-register the Django signal handlers after a restart -CLI usage: - seconddns add — Register domain as secondary zone - seconddns remove — Remove domain from secondary DNS - seconddns sync — Sync all CyberPanel domains with secondary DNS - seconddns list — List zones on secondary DNS +Zone management from the command line is `seconddns`, shared by every panel. Configuration: /etc/seconddns.conf """ @@ -228,38 +224,7 @@ def get_cyberpanel_domains(): return domains -def sync(config): - local_domains = set(get_cyberpanel_domains()) - remote_zones = list_zones(config) - remote_domains = {z["name"].rstrip("."): z for z in remote_zones} - logger.info("Local domains: %d, Remote zones: %d", len(local_domains), len(remote_domains)) - - added = 0 - for domain in sorted(local_domains - set(remote_domains.keys())): - if add_zone(config, domain): - added += 1 - - removed = 0 - for domain in sorted(set(remote_domains.keys()) - local_domains): - if remove_zone(config, domain): - removed += 1 - - logger.info("Sync complete: +%d added, -%d removed", added, removed) - - -def cmd_list(config): - zones = list_zones(config) - if not zones: - print("No zones found.") - return - print(f"{'Name':<40} {'Status':<10} {'Master IP':<18} {'Last Sync'}") - print("-" * 90) - for z in zones: - print(f"{z['name']:<40} {z.get('status','?'):<10} {z.get('masterIp','?'):<18} {z.get('lastSync','never')}") - - -# --- Django signal handlers (used when loaded as CyberPanel plugin) --- def _extract_domain(request, response=None): """Extract domain name from CyberPanel request/response.""" @@ -536,15 +501,7 @@ def main(): if not config: sys.exit(1) - if cmd == "add" and len(sys.argv) >= 3: - add_zone(config, sys.argv[2]) - elif cmd == "remove" and len(sys.argv) >= 3: - remove_zone(config, sys.argv[2]) - elif cmd == "sync": - sync(config) - elif cmd == "list": - cmd_list(config) - elif cmd == "ensure-signals": + if cmd == "ensure-signals": ensure_signals() else: print(__doc__) diff --git a/hosting-panels/cyberpanel/uninstall.sh b/hosting-panels/cyberpanel/uninstall.sh index e0f94f4..4c2e93d 100755 --- a/hosting-panels/cyberpanel/uninstall.sh +++ b/hosting-panels/cyberpanel/uninstall.sh @@ -92,7 +92,7 @@ echo "Delete them manually via Dashboard or API if needed." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/directadmin/install.sh b/hosting-panels/directadmin/install.sh index ff052fb..94c3d27 100644 --- a/hosting-panels/directadmin/install.sh +++ b/hosting-panels/directadmin/install.sh @@ -222,6 +222,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -230,7 +231,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") @@ -343,7 +344,8 @@ if [ -n "$DNS_IPS" ]; then if [ "$ISSUES" -gt 0 ]; then if confirm "Apply PowerDNS fixes automatically? (backup will be created)"; then - cp "$PDNS_CONF" "${PDNS_CONF}.bak.$(date +%s)" + PDNS_BAK="${PDNS_CONF}.bak.$(date +%s)" + cp "$PDNS_CONF" "$PDNS_BAK" grep -qE "^master=yes" "$PDNS_CONF" || echo "master=yes" >> "$PDNS_CONF" grep -qE "^default-soa-edit=" "$PDNS_CONF" || echo "default-soa-edit=INCEPTION-INCREMENT" >> "$PDNS_CONF" @@ -363,7 +365,17 @@ if [ -n "$DNS_IPS" ]; then fi systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null - echo "[+] PowerDNS configured and restarted" + sleep 2 + if systemctl is-active --quiet pdns 2>/dev/null || pgrep -x pdns_server >/dev/null; then + echo "[+] PowerDNS configured and restarted" + else + # Saying "OK" while the DNS server is down is worse than + # failing: restore what was there and let a human look. + cp "$PDNS_BAK" "$PDNS_CONF" + systemctl restart pdns 2>/dev/null || service pdns restart 2>/dev/null + echo "[!] PowerDNS did not come back — configuration restored from the backup" + echo "[!] Check: journalctl -u pdns -n 20" + fi fi else echo "[+] PowerDNS AXFR config OK" diff --git a/hosting-panels/directadmin/uninstall.sh b/hosting-panels/directadmin/uninstall.sh index cb5a234..0d00002 100644 --- a/hosting-panels/directadmin/uninstall.sh +++ b/hosting-panels/directadmin/uninstall.sh @@ -36,7 +36,7 @@ echo " Your zones on the secondary DNS are not deleted automatically." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS" diff --git a/hosting-panels/plesk/install.sh b/hosting-panels/plesk/install.sh index 14b9b97..a5507a2 100755 --- a/hosting-panels/plesk/install.sh +++ b/hosting-panels/plesk/install.sh @@ -228,6 +228,7 @@ done echo "" echo "--- Installing offline operation queue ---" COMMON_URL="${REPO_URL%/*}/common" +curl -sf --max-time 10 -o /usr/local/bin/seconddns "$COMMON_URL/seconddns?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-domain "$COMMON_URL/seconddns-domain?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-owner "$COMMON_URL/seconddns-owner?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-migrate-master "$COMMON_URL/seconddns-migrate-master?t=$(date +%s)" @@ -236,7 +237,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns_common.py "$COMMON_URL/second curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-queue?t=$(date +%s)" curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)" curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)" -chmod +x /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +chmod +x /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued bash <(curl -sf --max-time 10 "$COMMON_URL/install-idn2.sh?t=$(date +%s)") mkdir -p /var/lib/seconddns bash <(curl -sf --max-time 10 "$COMMON_URL/install-sqlite.sh?t=$(date +%s)") diff --git a/hosting-panels/plesk/uninstall.sh b/hosting-panels/plesk/uninstall.sh index 7589aa1..8b7bb71 100755 --- a/hosting-panels/plesk/uninstall.sh +++ b/hosting-panels/plesk/uninstall.sh @@ -126,7 +126,7 @@ echo " Your zones on the secondary DNS are not deleted automatically." systemctl disable --now seconddns-queued.service 2>/dev/null rm -f /etc/systemd/system/seconddns-queued.service systemctl daemon-reload 2>/dev/null -rm -f /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued +rm -f /usr/local/bin/seconddns /usr/local/bin/seconddns-domain /usr/local/bin/seconddns-owner /usr/local/bin/seconddns-migrate-master /usr/local/bin/seconddns-reconcile /usr/local/bin/seconddns_common.py /usr/local/bin/seconddns-queue /usr/local/bin/seconddns-queued if [ -f /var/lib/seconddns/queue.db ] && command -v sqlite3 &>/dev/null; then n=$(sqlite3 /var/lib/seconddns/queue.db "SELECT COUNT(*) FROM ops WHERE status='pending';" 2>/dev/null || echo 0) [ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS"