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
15 changes: 15 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
203 changes: 203 additions & 0 deletions hosting-panels/common/seconddns
Original file line number Diff line number Diff line change
@@ -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 <domain> one zone: known here, mastered by whom, last sync
seconddns add <domain> register a zone
seconddns remove <domain> 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())
18 changes: 18 additions & 0 deletions hosting-panels/common/seconddns_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import configparser
import json
import os
import socket
import subprocess
import sys
import urllib.error
Expand Down Expand Up @@ -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:
Expand Down
107 changes: 107 additions & 0 deletions hosting-panels/common/tests/test_cli.sh
Original file line number Diff line number Diff line change
@@ -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" <<CONF
[seconddns]
api_url = http://127.0.0.1:$PORT
api_key = test-key
master_ip = 192.0.2.10
CONF
printf '#!/bin/bash\necho "$*" >> "$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 ]
Loading