diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dbee3f6..bc20444 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,6 +60,25 @@ jobs: - name: test_plesk_acl.sh run: bash hosting-panels/common/tests/test_plesk_acl.sh + - name: test_tsig_plesk.sh + run: bash hosting-panels/common/tests/test_tsig_plesk.sh + + # needs the bind9 installed above + - name: test_tsig_plesk_named.sh + run: | + out=$(bash hosting-panels/common/tests/test_tsig_plesk_named.sh); rc=$? + echo "$out" + echo "$out" | grep -q '^skipped' && { echo "::error::BIND missing on the runner"; exit 1; } + exit $rc + + # named-checkconf -t needs root (chroot) + - name: test_tsig_plesk_chroot.sh + run: | + out=$(sudo bash hosting-panels/common/tests/test_tsig_plesk_chroot.sh); rc=$? + echo "$out" + echo "$out" | grep -q '^skipped' && { echo "::error::test skipped on the runner"; exit 1; } + exit $rc + # needs the bind9 installed by the step above - name: test_plesk_acl_named.sh run: | diff --git a/hosting-panels/common/seconddns-tsig b/hosting-panels/common/seconddns-tsig index 552cf8c..90289dd 100755 --- a/hosting-panels/common/seconddns-tsig +++ b/hosting-panels/common/seconddns-tsig @@ -21,7 +21,8 @@ seconddns-tsig disable [--zones|--no-zones] undo enable: our IP back in allow-transfer, key removed, zones back to unsigned seconddns-tsig restore put the key back on BIND if the panel rewrote - its configuration (quiet when nothing is missing) + its configuration (quiet when nothing is missing); + on Plesk a systemd path unit runs it after each rewrite seconddns-tsig setup [--tsig=…] installer step: with --tsig, enable; on a terminal, ask whether transfers go by our IP or by a TSIG key; otherwise keep our IP @@ -120,8 +121,8 @@ def our_ips(cfg): return [x.strip() for x in str(info.get("dnsIps", "")).split(",") if x.strip()] -def checkconf_ok(): - cmd = shlex.split(os.environ.get("SECONDDNS_NAMED_CHECKCONF", "named-checkconf")) +def checkconf_ok(args=()): + cmd = shlex.split(os.environ.get("SECONDDNS_NAMED_CHECKCONF", "named-checkconf")) + list(args) try: return subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0 except (OSError, subprocess.TimeoutExpired): @@ -173,7 +174,7 @@ def write_key_file(path, key, secret): os.replace(tmp, path) -def edit_named(options, change): +def edit_named(options, change, check_args=()): """Apply change(text) -> (text, error) to the options file; keep it only when named-checkconf accepts the result, then reload.""" with open(options) as f: @@ -187,7 +188,7 @@ def edit_named(options, change): shutil.copy2(options, backup) with open(options, "w") as f: f.write(after) - if not checkconf_ok(): + if not checkconf_ok(check_args): shutil.copy2(backup, options) sys.exit(f"{options}: named-checkconf rejected the change; restored from {backup}") # named-checkconf does not read key files the way named does: a reload that fails @@ -199,35 +200,103 @@ def edit_named(options, change): return True -def key_file_for(options): - return os.path.join(os.path.dirname(options), bind.KEY_FILE_NAME) - - -def add_key_to_bind(key, secret, options): - """Key file, include, and the key added to allow-transfer next to whatever is there.""" - key_file = key_file_for(options) - existed = os.path.exists(key_file) - write_key_file(key_file, key, secret) +class Target: + """Where the key lives and which list lets transfers in. + + bind: allow-transfer in the options block, include appended to the options file. + plesk: every zone has allow-transfer { common-allow-transfer; }, so the list is that + ACL; the include goes into Plesk's custom directives block (kept on rewrite); + named runs chrooted, so the include names the key file as named sees it.""" + + def __init__(self, conf, flavor): + self.conf, self.flavor = conf, flavor + self.check_args = () + if flavor == "plesk": + chroot = os.environ.get("SECONDDNS_PLESK_CHROOT", "/var/named/chroot") + self.key_file = os.path.join(chroot or os.path.dirname(conf), "etc" if chroot else "", bind.KEY_FILE_NAME) + self.include_path = self.key_file[len(chroot):] if chroot else self.key_file + if chroot and conf.startswith(chroot.rstrip("/") + "/"): + # named runs with -t : the include names the key as named sees it, + # so the check has to resolve paths inside the chroot too + self.check_args = ("-t", chroot, conf[len(chroot.rstrip("/")):]) + else: + self.key_file = os.path.join(os.path.dirname(conf), bind.KEY_FILE_NAME) + self.include_path = self.key_file + self.where = f"acl {bind.PLESK_ACL}" if flavor == "plesk" else "allow-transfer" + + def edit(self, text, add=(), drop=()): + return (bind.edit_acl if self.flavor == "plesk" else bind.edit_allow_transfer)(text, add=add, drop=drop) + + def entries(self, text): + return (bind.acl_entries if self.flavor == "plesk" else bind.allow_transfer_entries)(text) + + def with_include(self, text): + if self.flavor == "plesk": + return bind.add_include_custom(text, self.include_path) + return bind.add_include(text, self.include_path), None + + def without_include(self, text): + return bind.remove_include(text, self.include_path) + + def complete(self, text, key_name, dropped): + """Key file, include and key in the list, and none of the dropped entries back.""" + return (os.path.exists(self.key_file) + and bind.include_line(self.include_path).strip() in text + and bind.key_entry(key_name) in self.entries(text) + and not any(x in self.entries(text) for x in dropped)) + + +def detect_target(): + flavor = os.environ.get("SECONDDNS_TSIG_FLAVOR") or ("plesk" if shutil.which("plesk") and os.path.isdir("/usr/local/psa") else "bind") + if flavor == "plesk": + forced = os.environ.get("SECONDDNS_NAMED_OPTIONS") + conf = forced if forced else os.path.realpath("/etc/named.conf") + if not os.path.isfile(conf) or (not forced and subprocess.run(["pgrep", "-x", "named"], capture_output=True).returncode != 0): + return None + return Target(conf, "plesk") + options = named_options() + return Target(options, "bind") if options else None + + +def edit_named_t(t, change): + return edit_named(t.conf, change, t.check_args) + + +def chain(*steps): + """Compose (text) -> (text, error) steps into one change for edit_named.""" + def run(text): + for step in steps: + text, err = step(text) + if err: + return text, err + return text, None + return run + + +def add_key_to_bind(key, secret, t): + """Key file, include, and the key added to the list next to whatever is there.""" + existed = os.path.exists(t.key_file) + write_key_file(t.key_file, key, secret) try: - edit_named(options, lambda t: bind.edit_allow_transfer(bind.add_include(t, key_file), add=[bind.key_entry(key["name"])])) + edit_named_t(t, chain(t.with_include, lambda x: t.edit(x, add=[bind.key_entry(key["name"])]))) except SystemExit: if not existed: - os.remove(key_file) + os.remove(t.key_file) raise - return key_file + return t.key_file -def drop_ips_from_bind(ips, options): - """Remove our IPs from allow-transfer; returns the entries actually removed, as written.""" - with open(options) as fh: - before = bind.allow_transfer_entries(fh.read()) +def drop_ips_from_bind(ips, t): + """Remove our IPs from the list; returns the entries actually removed, as written.""" + with open(t.conf) as fh: + before = t.entries(fh.read()) removed = [x for x in before if x.strip('"') in set(ips)] - edit_named(options, lambda t: bind.edit_allow_transfer(t, drop=ips)) + edit_named_t(t, lambda x: t.edit(x, drop=ips)) return removed -def ips_back_to_bind(entries, options): - edit_named(options, lambda t: bind.edit_allow_transfer(t, add=list(entries))) +def ips_back_to_bind(entries, t): + edit_named_t(t, lambda x: t.edit(x, add=list(entries))) def add_section_value(config, name, value): @@ -237,11 +306,45 @@ def add_section_value(config, name, value): write_section(config, body + f"{name} = {value}\n") -def remove_key_from_bind(key_name, options): - key_file = key_file_for(options) - edit_named(options, lambda t: bind.edit_allow_transfer(bind.remove_include(t, key_file), drop=[bind.key_entry(key_name)])) - if os.path.exists(key_file): - os.remove(key_file) +def remove_key_from_bind(key_name, t): + edit_named_t(t, chain(lambda x: (t.without_include(x), None), lambda x: t.edit(x, drop=[bind.key_entry(key_name)]))) + if os.path.exists(t.key_file): + os.remove(t.key_file) + + +# Plesk rewrites named.conf from its database (ACL without our key); restore puts the key +# back within seconds of any rewrite, the timer is the fallback. +SYSTEMD_DIR = os.environ.get("SECONDDNS_SYSTEMD_DIR", "/etc/systemd/system") +UNITS = { + "seconddns-tsig-restore.service": "[Unit]\nDescription=SecondDNS: put the TSIG key back into named.conf\n\n[Service]\nType=oneshot\nExecStart=/usr/local/bin/seconddns-tsig restore\n", + "seconddns-tsig-restore.path": "[Unit]\nDescription=SecondDNS: watch named.conf for Plesk rewrites\n\n[Path]\nPathChanged={conf}\nUnit=seconddns-tsig-restore.service\n\n[Install]\nWantedBy=multi-user.target\n", + "seconddns-tsig-restore.timer": "[Unit]\nDescription=SecondDNS: periodic TSIG key check\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=5min\nUnit=seconddns-tsig-restore.service\n\n[Install]\nWantedBy=timers.target\n", +} + + +def systemctl(*args): + cmd = shlex.split(os.environ.get("SECONDDNS_SYSTEMCTL", "systemctl")) + list(args) + try: + return subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def install_restore_units(conf): + for name, body in UNITS.items(): + with open(os.path.join(SYSTEMD_DIR, name), "w") as f: + f.write(body.replace("{conf}", conf)) + systemctl("daemon-reload") + systemctl("enable", "--now", "seconddns-tsig-restore.path", "seconddns-tsig-restore.timer") + + +def remove_restore_units(): + systemctl("disable", "--now", "seconddns-tsig-restore.path", "seconddns-tsig-restore.timer") + for name in UNITS: + path = os.path.join(SYSTEMD_DIR, name) + if os.path.exists(path): + os.remove(path) + systemctl("daemon-reload") def restart_worker(): @@ -328,7 +431,7 @@ def main(): a = ap.parse_args() if a.cmd == "setup": - if not named_options(): + if not detect_target(): if a.tsig: sys.exit("--tsig: BIND (named) is not running here; TSIG for PowerDNS panels is not supported yet") return @@ -348,8 +451,8 @@ def main(): a.cmd, a.tsig, a.zones = "enable", pair, True if a.cmd in ("enable", "disable", "restore"): - options = named_options() - if not options: + t = detect_target() + if not t: sys.exit("BIND (named) is not running here; TSIG for PowerDNS panels is not supported yet") cfg = load_config(a.config) cp = configparser.ConfigParser() @@ -358,58 +461,68 @@ def main(): # 1. key accepted next to our IPs, 2. zones switch to the key, 3. only then our # IPs go: an unsigned request from a zone not yet switched is never refused key, secret = resolve_pair(a.config, a.tsig) - key_file = add_key_to_bind(key, secret, options) + key_file = add_key_to_bind(key, secret, t) store(a.config, key, secret) restart_worker() - print(f"[+] TSIG key {key['name']} installed: {key_file}, accepted in allow-transfer of {options}") - with open(options) as fh: - zl = bind.zone_level_transfers(fh.read(), our_ips(cfg)) - if zl: - print(f"[!] {zl} zone-level allow-transfer entr{'y' if zl == 1 else 'ies'} name our IP and override options.") - print(f" In WHM > Service Configuration > DNS Server (BIND) > Additional zone configuration,") - print(f" replace our IP with: allow-transfer {{ key \"{key['name']}\"; }};") + print(f"[+] TSIG key {key['name']} installed: {key_file}, accepted in {t.where} of {t.conf}") + if t.flavor == "bind": + with open(t.conf) as fh: + zl = bind.zone_level_transfers(fh.read(), our_ips(cfg)) + if zl: + print(f"[!] {zl} zone-level allow-transfer entr{'y' if zl == 1 else 'ies'} name our IP and override options.") + print(f" In WHM > Service Configuration > DNS Server (BIND) > Additional zone configuration,") + print(f" replace our IP with: allow-transfer {{ key \"{key['name']}\"; }};") if set_zone_keys(cfg, this_servers_zones(cfg), key["id"], a.zones, "Set the key"): - removed = drop_ips_from_bind(our_ips(cfg), options) + removed = drop_ips_from_bind(our_ips(cfg), t) if removed: add_section_value(a.config, "removed_entries", ",".join(removed)) - print("[+] allow-transfer: our IPs removed, transfers to SecondDNS need the key") + print(f"[+] {t.where}: our IPs removed, transfers to SecondDNS need the key") else: - print("[!] our IPs stay in allow-transfer until every zone has the key: seconddns-tsig enable --tsig=... --zones") + print(f"[!] our IPs stay in {t.where} until every zone has the key: seconddns-tsig enable --tsig=... --zones") + if t.flavor == "plesk": + install_restore_units(t.conf) + print("[+] Plesk rewrites named.conf from its own settings; seconddns-tsig-restore.path puts the key back after each rewrite") elif a.cmd == "disable": if not cp.has_section("tsig"): print("TSIG: already off") return - t = cp["tsig"] - saved = dict(t) + saved = dict(cp["tsig"]) + if t.flavor == "plesk": + remove_restore_units() # it would put the key back halfway through # what enable took out; an install that never dropped anything gets the first IP back = [x for x in saved.get("removed_entries", "").split(",") if x] or our_ips(cfg)[:1] # mirror of enable: our IPs back, new zones unsigned, zones off the key, key goes - ips_back_to_bind(back, options) + ips_back_to_bind(back, t) write_section(a.config, "") restart_worker() mine = [z for z in this_servers_zones(cfg) if z.get("tsigKeyId") == saved["key_id"]] if not set_zone_keys(cfg, mine, None, a.zones, "Remove the key"): write_section(a.config, "".join(f"{k} = {v}\n" for k, v in saved.items())) restart_worker() + if t.flavor == "plesk": + install_restore_units(t.conf) sys.exit("[!] the key stays on BIND while zones still use it: seconddns-tsig disable --zones") - remove_key_from_bind(saved["key_name"], options) + remove_key_from_bind(saved["key_name"], t) f = saved.get("secret_file", "") if f and os.path.exists(f): os.remove(f) - print(f"[+] TSIG off: our IP is back in allow-transfer in {options}, key removed") + print(f"[+] TSIG off: our IP is back in {t.where} of {t.conf}, key removed") else: if not cp.has_section("tsig"): return - t = cp["tsig"] - key_file = key_file_for(options) - with open(options) as fh: + saved = cp["tsig"] + dropped = [x for x in saved.get("removed_entries", "").split(",") if x] + with open(t.conf) as fh: text = fh.read() - if os.path.exists(key_file) and bind.include_line(key_file).strip() in text and bind.has_key_in_allow_transfer(text, t["key_name"]): + if t.complete(text, saved["key_name"], dropped): return - with open(t["secret_file"]) as fh: + with open(saved["secret_file"]) as fh: secret = fh.read().strip() - add_key_to_bind({"id": t["key_id"], "name": t["key_name"], "algorithm": t["algorithm"]}, secret, options) - print(f"[+] TSIG key {t['key_name']} restored in {options}") + add_key_to_bind({"id": saved["key_id"], "name": saved["key_name"], "algorithm": saved["algorithm"]}, secret, t) + if dropped: + # Plesk puts the template's IPs back on every rewrite; keep transfers key-only + drop_ips_from_bind([x.strip('"') for x in dropped], t) + print(f"[+] TSIG key {saved['key_name']} restored in {t.conf}") return if a.cmd == "check-secret": diff --git a/hosting-panels/common/seconddns_bind.py b/hosting-panels/common/seconddns_bind.py index 7097b89..aaf45bb 100644 --- a/hosting-panels/common/seconddns_bind.py +++ b/hosting-panels/common/seconddns_bind.py @@ -67,21 +67,10 @@ def key_block(name, algorithm, secret): return f'key "{name}" {{\n algorithm {algorithm};\n secret "{secret}";\n}};\n' -def edit_allow_transfer(text, add=(), drop=()): - """allow-transfer in the options block: remove the entries in drop (and a "none" - placeholder once something real is added), append the entries in add that are - missing. Other entries and comments stay where they are. Returns (new_text, error).""" - mask = _code_mask(text) - span = _options_span(text, mask) - if not span: - return text, "no options { } block" - s, e = span - m = re.search(r"\ballow-transfer\s*\{([^{}]*)\}\s*;", mask[s:e]) - if not m: - if not add: - return text, None - return text[:e] + " allow-transfer { " + "; ".join(add) + "; };\n" + text[e:], None - a, b = s + m.start(1), s + m.end(1) +def _edit_list(text, mask, a, b, add, drop, none_literal): + """Edit the entries between a and b (the inside of a { ... } list): remove those in + drop (and a none placeholder once something real is added), append those in add + that are missing. Other entries and comments stay where they are.""" body, body_mask = text[a:b], mask[a:b] values = _code_mask(text, keep_strings=True)[a:b] # entries end at ';' in real config; text in between (comments, spacing) is kept @@ -98,7 +87,7 @@ def edit_allow_transfer(text, add=(), drop=()): gone.add("none") kept = "".join(body[p:q] for p, q, v in entries if v.strip('"') not in gone) if not kept.strip() and not added: - new = ' "none"; ' + new = f" {none_literal}; " elif "\n" in body: # one entry per line: new ones go on their own lines, after a trailing comment indent = next((ln[:len(ln) - len(ln.lstrip())] for ln in body.splitlines() if ln.strip()), " ") @@ -106,7 +95,22 @@ def edit_allow_transfer(text, add=(), drop=()): new = kept + first + "".join(f"\n{indent}{x};" for x in added) + (nl + rest if nl else "\n") else: new = " " + " ".join(part for part in [kept.strip(), " ".join(f"{x};" for x in added)] if part) + " " - return text[:a] + new + text[b:], None + return text[:a] + new + text[b:] + + +def edit_allow_transfer(text, add=(), drop=()): + """allow-transfer in the options block, edited with _edit_list. Returns (new_text, error).""" + mask = _code_mask(text) + span = _options_span(text, mask) + if not span: + return text, "no options { } block" + s, e = span + m = re.search(r"\ballow-transfer\s*\{([^{}]*)\}\s*;", mask[s:e]) + if not m: + if not add: + return text, None + return text[:e] + " allow-transfer { " + "; ".join(add) + "; };\n" + text[e:], None + return _edit_list(text, mask, s + m.start(1), s + m.end(1), add, drop, '"none"'), None def key_entry(name): @@ -164,3 +168,49 @@ def allow_transfer_entries(text): return [] values = _code_mask(text, keep_strings=True)[s + m.start(1):s + m.end(1)] return [x.strip() for x in values.split(";") if x.strip()] + + +# --- Plesk: zones carry allow-transfer { common-allow-transfer; } --------------------- + +PLESK_ACL = "common-allow-transfer" +PLESK_CUSTOM_MARK = "PLEASE ADD YOUR CUSTOM DIRECTIVES BELOW THIS LINE" + + +def _acl_body(text, mask): + m = re.search(r"\bacl\s+" + re.escape(PLESK_ACL) + r"\s*\{([^{}]*)\}\s*;", mask) + return (m.start(1), m.end(1)) if m else None + + +def edit_acl(text, add=(), drop=()): + """acl common-allow-transfer, which Plesk includes in every zone. Returns (new_text, error).""" + mask = _code_mask(text) + span = _acl_body(text, mask) + if not span: + return text, f"no acl {PLESK_ACL} {{ }} block" + return _edit_list(text, mask, span[0], span[1], add, drop, "none"), None + + +def acl_entries(text): + mask = _code_mask(text) + span = _acl_body(text, mask) + if not span: + return [] + values = _code_mask(text, keep_strings=True)[span[0]:span[1]] + return [x.strip() for x in values.split(";") if x.strip()] + + +def add_include_custom(text, key_file): + """The include goes into Plesk's custom directives block, which Plesk keeps when it + rewrites named.conf. Returns (new_text, error).""" + line = include_line(key_file) + if line.strip() in text: + return text, None + out, done = [], False + for ln in text.splitlines(True): + out.append(ln) + if not done and PLESK_CUSTOM_MARK in ln: + out.append(line) + done = True + if not done: + return text, f"no \"{PLESK_CUSTOM_MARK}\" line in named.conf" + return "".join(out), None diff --git a/hosting-panels/common/tests/fixtures/plesk-named.conf b/hosting-panels/common/tests/fixtures/plesk-named.conf new file mode 100644 index 0000000..e0ef4fc --- /dev/null +++ b/hosting-panels/common/tests/fixtures/plesk-named.conf @@ -0,0 +1,41 @@ +// Shape of a Plesk Obsidian 18.0.81 named.conf (AlmaLinux 9, BIND in chroot); +// addresses and zone names replaced. +options { + allow-recursion { + localnets; + }; + directory "/var"; + pid-file "/var/run/named/named.pid"; +}; +key "rndc-key" { + algorithm hmac-md5; + secret "c2Vjb25kZG5zLXRlc3QtZml4dHVyZQ=="; +}; +controls { + inet 127.0.0.1 port 953 + allow { 127.0.0.1; } keys { "rndc-key"; }; +}; +zone "example-one.test" { + type master; + file "example-one.test"; + allow-transfer { + common-allow-transfer; + }; +}; +zone "example-two.test" { + type master; + file "example-two.test"; + allow-transfer { + common-allow-transfer; + }; +}; +acl common-allow-transfer { + 198.51.100.7; + 203.0.113.9; +}; +// -- END OF LINES GENERATED BY PLESK. -- + + +// -- PLEASE ADD YOUR CUSTOM DIRECTIVES BELOW THIS LINE. -- +// ... +// -- END OF YOUR CUSTOM DIRECTIVES. -- diff --git a/hosting-panels/common/tests/test_tsig_plesk.sh b/hosting-panels/common/tests/test_tsig_plesk.sh new file mode 100755 index 0000000..79512b2 --- /dev/null +++ b/hosting-panels/common/tests/test_tsig_plesk.sh @@ -0,0 +1,152 @@ +#!/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-tsig on Plesk: key file in the chroot, include in the custom directives block, +# key in acl common-allow-transfer, and restore after Plesk rewrites named.conf. Mock API, +# stand-ins for named-checkconf, rndc and systemctl. The real named run is +# test_tsig_plesk_named.sh. Requires: bash, python3. No network. +# +# Usage: ./test_tsig_plesk.sh + +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +TSIG="$HERE/../seconddns-tsig" +FIXTURE="$HERE/fixtures/plesk-named.conf" +TMP="$(mktemp -d)" +PORT=8894 +CHROOT="$TMP/chroot" +CONF="$CHROOT/etc/named.conf" +KEYFILE="$CHROOT/etc/seconddns-tsig.key" + +export SECONDDNS_TSIG_FLAVOR=plesk +export SECONDDNS_PLESK_CHROOT="$CHROOT" +export SECONDDNS_NAMED_OPTIONS="$CONF" +export SECONDDNS_CONF="$TMP/seconddns.conf" +export SECONDDNS_TSIG_SECRET="$TMP/etc-seconddns/tsig.secret" +export SECONDDNS_NAMED_CHECKCONF="sh -c 'echo \"\$*\" >> $TMP/checkconf.log; test ! -e $TMP/checkconf-fails' checkconf" +export SECONDDNS_NAMED_RELOAD="true" +export SECONDDNS_NAMED_GID="$(id -g)" +export SECONDDNS_QUEUED_RESTART="true" +export SECONDDNS_SYSTEMD_DIR="$TMP/systemd" +export SECONDDNS_SYSTEMCTL="sh -c 'echo \"\$*\" >> $TMP/systemctl.log' systemctl" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } +assert_eq() { [ "$1" = "$2" ] && ok "$3" || fail "$3 (expected '$2', got '$1')"; } +acl() { awk '/^acl common-allow-transfer/,/^};/' "$CONF" | grep -v -e '^acl' -e '^};' | tr -d '\t;' | tr '\n' ' ' | sed 's/ $//'; } + +# what Plesk writes on every rewrite: the ACL from its template, the custom block as it was +plesk_rewrite() { + python3 - "$CONF" <<'PY' +import re, sys +p = sys.argv[1]; s = open(p).read() +s = re.sub(r"acl common-allow-transfer \{.*?\};", "acl common-allow-transfer {\n\t198.51.100.7;\n\t203.0.113.9;\n};", s, flags=re.S) +open(p, "w").write(s) +PY +} + +reset() { + rm -rf "$CHROOT" "$TMP/systemd" "$TMP/systemctl.log" "$TMP/checkconf.log" "$TMP/checkconf-fails" "$SECONDDNS_TSIG_SECRET" + mkdir -p "$CHROOT/etc" "$TMP/systemd" + cp "$FIXTURE" "$CONF" + : > "$TMP/patches.log" + cat > "$SECONDDNS_CONF" < "$TMP/zones" +} + +SECRET=$(python3 -c 'import base64,os;print(base64.b64encode(os.urandom(32)).decode())') +echo '[{"id":"k1","label":"web","name":"acct-web","algorithm":"hmac-sha256","isDefault":false}]' > "$TMP/keys" + +python3 - "$PORT" "$TMP" > /dev/null 2>&1 <<'PYEOF' & +import http.server, sys, json +port, tmp = int(sys.argv[1]), sys.argv[2] +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def reply(self, code, body): + self.send_response(code); self.send_header('Content-Type','application/json'); self.end_headers() + self.wfile.write(body if isinstance(body, bytes) else json.dumps(body).encode()) + def do_GET(self): + p = self.path.split('?')[0] + if p == '/api/user/tsig-keys': self.reply(200, open(tmp+'/keys','rb').read()) + elif p == '/api/server-info': self.reply(200, {"dnsIps": "203.0.113.9"}) + elif p == '/api/zones': self.reply(200, open(tmp+'/zones','rb').read()) + else: self.reply(404, {}) + def do_PATCH(self): + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))) or b'{}') + zones = json.load(open(tmp+'/zones')) + for z in zones: z['tsigKeyId'] = body.get('tsigKeyId') + json.dump(zones, open(tmp+'/zones','w')) + with open(tmp+'/patches.log','a') as f: f.write(f"{body.get('tsigKeyId')}\n") + self.reply(200, {}) +http.server.HTTPServer(('127.0.0.1', port), H).serve_forever() +PYEOF +MOCK_PID=$! +disown $MOCK_PID 2>/dev/null || true +trap 'kill $MOCK_PID 2>/dev/null; rm -rf "$TMP"' EXIT +sleep 1 + +echo "== 1. enable --zones on a Plesk named.conf" +reset +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1; rc=$? +assert_eq "$rc" 0 "enable exits 0" +assert_eq "$(sort -u "$TMP/checkconf.log")" "-t $CHROOT /etc/named.conf" "named-checkconf runs inside the chroot, where the include resolves" +[ -e "$KEYFILE" ] && grep -q "secret \"$SECRET\";" "$KEYFILE" && ok "key file inside the chroot" || fail "key file missing in $CHROOT/etc" +awk '/PLEASE ADD YOUR CUSTOM DIRECTIVES/{getline; print; exit}' "$CONF" | grep -q '^include "/etc/seconddns-tsig.key"; // seconddns-tsig$' \ + && ok "include right below the custom directives line, chroot path" || fail "include: $(grep -n seconddns-tsig "$CONF")" +assert_eq "$(acl)" '198.51.100.7 key "acct-web"' "our IP out of the ACL, key in, customer's secondary kept" +assert_eq "$(grep -c 'common-allow-transfer;' "$CONF")" 2 "zones untouched" +grep -q '^removed_entries = 203.0.113.9$' "$SECONDDNS_CONF" && ok "removed IP remembered" || fail "removed_entries missing" +grep -q "^PathChanged=$CONF$" "$TMP/systemd/seconddns-tsig-restore.path" && ok "path unit watches named.conf" || fail "path unit" +grep -q '^ExecStart=/usr/local/bin/seconddns-tsig restore$' "$TMP/systemd/seconddns-tsig-restore.service" && ok "service runs restore" || fail "service unit" +[ -e "$TMP/systemd/seconddns-tsig-restore.timer" ] && ok "fallback timer written" || fail "no timer" +grep -q "enable --now seconddns-tsig-restore.path seconddns-tsig-restore.timer" "$TMP/systemctl.log" && ok "path unit and timer enabled" || fail "systemctl: $(cat "$TMP/systemctl.log")" + +echo "== 2. Plesk rewrites named.conf: restore puts the key back and our IP out again" +plesk_rewrite +assert_eq "$(acl)" '198.51.100.7 203.0.113.9' "after the rewrite: template ACL, no key" +"$TSIG" restore > "$TMP/out" 2>&1; rc=$? +assert_eq "$rc" 0 "restore exits 0" +assert_eq "$(acl)" '198.51.100.7 key "acct-web"' "key back, our IP out" +cp "$CONF" "$TMP/after" +"$TSIG" restore > "$TMP/out" 2>&1 +cmp -s "$TMP/after" "$CONF" && [ ! -s "$TMP/out" ] && ok "second restore is a quiet no-op (the path unit fires on our own write too)" || fail "second restore changed something" + +echo "== 3. custom block lost its include: restore puts it back" +python3 - "$CONF" <<'PY' +import sys; p=sys.argv[1]; s=open(p).read(); open(p,"w").write(s.replace('include "/etc/seconddns-tsig.key"; // seconddns-tsig\n', '')) +PY +"$TSIG" restore > /dev/null 2>&1 +grep -q 'include "/etc/seconddns-tsig.key"; // seconddns-tsig' "$CONF" && ok "include restored" || fail "include not restored" + +echo "== 4. disable --zones: units stop first, IP back, key and include gone" +: > "$TMP/systemctl.log"; : > "$TMP/patches.log" +"$TSIG" disable --zones > "$TMP/out" 2>&1; rc=$? +assert_eq "$rc" 0 "disable exits 0" +head -1 "$TMP/systemctl.log" | grep -q "^disable --now seconddns-tsig-restore.path" && ok "restore units stopped before any edit" || fail "systemctl order: $(cat "$TMP/systemctl.log")" +assert_eq "$(acl)" '198.51.100.7 203.0.113.9' "our IP back in the ACL, key gone" +grep -q seconddns-tsig "$CONF" && fail "include left" || ok "include removed" +[ -e "$KEYFILE" ] && fail "key file left" || ok "key file removed" +[ -e "$TMP/systemd/seconddns-tsig-restore.path" ] && fail "unit files left" || ok "unit files removed" +assert_eq "$(cat "$TMP/patches.log")" "None" "zone set back to unsigned" + +echo "== 5. no custom directives line: refused, nothing kept" +reset +grep -v "PLEASE ADD YOUR CUSTOM DIRECTIVES" "$FIXTURE" > "$CONF" +cp "$CONF" "$TMP/before" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 && fail "enable succeeded without the custom block" || ok "enable refuses" +grep -q "PLEASE ADD YOUR CUSTOM DIRECTIVES" "$TMP/out" && ok "says what is missing" || fail "message: $(cat "$TMP/out")" +cmp -s "$TMP/before" "$CONF" && ok "named.conf unchanged" || fail "named.conf edited" +[ -e "$KEYFILE" ] && fail "key file left" || ok "no key file" +grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "[tsig] stored" || ok "nothing stored" + +echo +echo "passed: $PASS, failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/common/tests/test_tsig_plesk_chroot.sh b/hosting-panels/common/tests/test_tsig_plesk_chroot.sh new file mode 100755 index 0000000..95c2dea --- /dev/null +++ b/hosting-panels/common/tests/test_tsig_plesk_chroot.sh @@ -0,0 +1,67 @@ +#!/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 +# The real named-checkconf against a Plesk-like chroot, through seconddns-tsig enable: the +# include names /etc/seconddns-tsig.key as named sees it from /var/named/chroot, so the +# check only passes when it runs with -t . Needs root (chroot) and +# named-checkconf; skips itself otherwise. +# +# Usage: sudo ./test_tsig_plesk_chroot.sh + +set -u + +[ "$(id -u)" -eq 0 ] || { echo "skipped: needs root for named-checkconf -t"; exit 0; } +command -v named-checkconf > /dev/null || { echo "skipped: named-checkconf not installed"; exit 0; } + +HERE="$(cd "$(dirname "$0")" && pwd)" +TSIG="$HERE/../seconddns-tsig" +TMP="$(mktemp -d)" +CHROOT="$TMP/chroot" +PORT=8892 + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +mkdir -p "$CHROOT/etc" "$CHROOT/var" +cp "$HERE/fixtures/plesk-named.conf" "$CHROOT/etc/named.conf" +cat > "$TMP/seconddns.conf" < /dev/null 2>&1 <<'PYEOF' & +import http.server, json, sys +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + p = self.path.split('?')[0] + body = ([{"id": "k1", "label": "web", "name": "acct-web", "algorithm": "hmac-sha256", "isDefault": False}] + if p == '/api/user/tsig-keys' else {"dnsIps": "203.0.113.9"} if p == '/api/server-info' else []) + self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers() + self.wfile.write(json.dumps(body).encode()) +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), H).serve_forever() +PYEOF +API_PID=$! +disown $API_PID 2>/dev/null || true +trap 'kill $API_PID 2>/dev/null; rm -rf "$TMP"' EXIT +sleep 1 + +SECRET=$(python3 -c 'import base64,os;print(base64.b64encode(os.urandom(32)).decode())') +export SECONDDNS_TSIG_FLAVOR=plesk SECONDDNS_PLESK_CHROOT="$CHROOT" SECONDDNS_NAMED_OPTIONS="$CHROOT/etc/named.conf" \ + SECONDDNS_CONF="$TMP/seconddns.conf" SECONDDNS_TSIG_SECRET="$TMP/tsig.secret" SECONDDNS_NAMED_RELOAD=true \ + SECONDDNS_NAMED_GID=0 SECONDDNS_QUEUED_RESTART=true SECONDDNS_SYSTEMD_DIR="$TMP" SECONDDNS_SYSTEMCTL=true +unset SECONDDNS_NAMED_CHECKCONF # the real one + +echo "== the check that enable runs, with the real named-checkconf" +named-checkconf "$CHROOT/etc/named.conf" > /dev/null 2>&1 && ok "fixture valid before" || fail "fixture invalid before enable" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 && ok "enable passes named-checkconf -t " || fail "enable rolled back: $(tail -2 "$TMP/out")" +grep -q 'include "/etc/seconddns-tsig.key"' "$CHROOT/etc/named.conf" && ok "include kept (not rolled back)" || fail "include missing after enable" +named-checkconf -t "$CHROOT" /etc/named.conf > /dev/null 2>&1 && ok "named-checkconf -t accepts the result" || fail "named-checkconf -t rejects the result" +named-checkconf "$CHROOT/etc/named.conf" > /dev/null 2>&1 && fail "outside the chroot the include resolved (the case this test exists for is not covered)" || ok "outside the chroot the include does not resolve, so -t is what makes it pass" + +echo +echo "passed: $PASS, failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/common/tests/test_tsig_plesk_named.sh b/hosting-panels/common/tests/test_tsig_plesk_named.sh new file mode 100755 index 0000000..b379c00 --- /dev/null +++ b/hosting-panels/common/tests/test_tsig_plesk_named.sh @@ -0,0 +1,159 @@ +#!/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-tsig on a real named with a Plesk-style named.conf: zones use +# allow-transfer { common-allow-transfer; }. After enable our IP needs the key; a Plesk +# rewrite (template ACL back, key gone) reopens our IP until restore puts the key back. +# named runs without chroot here (that needs root), so the key file sits next to the +# config. Requires: named, dig, python3. Skips itself when BIND is not installed. +# +# Usage: ./test_tsig_plesk_named.sh + +set -u + +for t in named named-checkconf dig; do + command -v "$t" > /dev/null || { echo "skipped: $t not installed"; exit 0; } +done + +HERE="$(cd "$(dirname "$0")" && pwd)" +TSIG="$HERE/../seconddns-tsig" +TMP="$(mktemp -d)" +API_PORT=8893 +DNS_PORT=5302 +CONF="$TMP/named.conf" + +export SECONDDNS_TSIG_FLAVOR=plesk +export SECONDDNS_PLESK_CHROOT="" +export SECONDDNS_NAMED_OPTIONS="$CONF" +export SECONDDNS_CONF="$TMP/seconddns.conf" +export SECONDDNS_TSIG_SECRET="$TMP/etc-seconddns/tsig.secret" +export SECONDDNS_NAMED_CHECKCONF="named-checkconf $CONF" +export SECONDDNS_NAMED_RELOAD="sh -c 'kill -HUP \$(cat $TMP/named.pid) && sleep 1'" +export SECONDDNS_NAMED_GID="$(id -g)" +export SECONDDNS_QUEUED_RESTART="true" +export SECONDDNS_SYSTEMD_DIR="$TMP" +export SECONDDNS_SYSTEMCTL="true" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +cat > "$TMP/a.example" <<'EOF' +$TTL 300 +@ IN SOA ns1.a.example. hostmaster.a.example. 1 300 60 86400 60 +@ IN NS ns1.a.example. +ns1 IN A 192.0.2.53 +www IN A 192.0.2.10 +EOF +# our IP is 127.0.0.1, the customer's other secondary 127.0.0.2 +template_acl='acl common-allow-transfer { + 127.0.0.2; + 127.0.0.1; +};' +cat > "$CONF" < "$SECONDDNS_CONF" < /dev/null 2>&1 <<'PYEOF' & +import http.server, sys, json +zones = [{"id": "z1", "name": "a.example", "masterIp": "192.0.2.10", "tsigKeyId": None}] +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def reply(self, code, body): + self.send_response(code); self.send_header('Content-Type','application/json'); self.end_headers() + self.wfile.write(json.dumps(body).encode()) + def do_GET(self): + p = self.path.split('?')[0] + if p == '/api/user/tsig-keys': + self.reply(200, [{"id": "k1", "label": "web", "name": "acct-web", "algorithm": "hmac-sha256", "isDefault": False}]) + elif p == '/api/server-info': self.reply(200, {"dnsIps": "127.0.0.1"}) + elif p == '/api/zones': self.reply(200, zones) + else: self.reply(404, {}) + def do_PATCH(self): + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))) or b'{}') + for z in zones: z['tsigKeyId'] = body.get('tsigKeyId') + self.reply(200, {}) +http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), H).serve_forever() +PYEOF +API_PID=$! +disown $API_PID 2>/dev/null || true +named -c "$CONF" -g > "$TMP/named.log" 2>&1 & +NAMED_PID=$! +disown $NAMED_PID 2>/dev/null || true +trap 'kill $API_PID $NAMED_PID 2>/dev/null; rm -rf "$TMP"' EXIT +for i in $(seq 1 20); do dig +short -p $DNS_PORT @127.0.0.1 a.example SOA > /dev/null 2>&1 && break; sleep 0.5; done + +transfers() { dig +tries=1 +time=3 -p $DNS_PORT @127.0.0.1 -b "$1" ${2:+-y "$2"} a.example AXFR 2>&1 | grep -q 'www.a.example.*192.0.2.10'; } +KEY="hmac-sha256:acct-web:$SECRET" + +echo "== 0. before: our IP transfers by IP" +transfers 127.0.0.1 && ok "baseline" || fail "baseline broken" + +echo "== 1. enable --zones" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 || fail "enable: $(cat "$TMP/out")" +transfers 127.0.0.1 && fail "our IP still transfers without the key" || ok "our IP without the key is refused" +transfers 127.0.0.1 "$KEY" && ok "our IP with the key transfers" || fail "signed AXFR failed" +transfers 127.0.0.2 && ok "the customer's other secondary still transfers by IP" || fail "customer's secondary lost access" + +echo "== 2. Plesk rewrites the ACL (custom block kept), then restore" +rewrite_acl() { + python3 - "$CONF" "$template_acl" <<'PY' +import re, sys +p, acl = sys.argv[1], sys.argv[2]; s = open(p).read() +open(p, "w").write(re.sub(r"acl common-allow-transfer \{.*?\};", acl, s, flags=re.S)) +PY + kill -HUP "$(cat "$TMP/named.pid")"; sleep 1 +} +rewrite_acl +# SecondDNS signs every request for a zone with a key; named still knows the key through +# the include in the custom block, and the template ACL lets our IP in +transfers 127.0.0.1 "$KEY" && ok "signed AXFR still passes in the window (key known, IP back in the ACL)" || fail "signed AXFR refused after an ACL-only rewrite" +transfers 127.0.0.1 && ok "unsigned from our IP is open in the window" || fail "rewrite simulation did not reopen our IP" +"$TSIG" restore > "$TMP/out" 2>&1 || fail "restore: $(cat "$TMP/out")" +transfers 127.0.0.1 && fail "restore left our IP open" || ok "after restore our IP needs the key again" +transfers 127.0.0.1 "$KEY" && ok "signed AXFR works after restore" || fail "signed AXFR failed after restore" + +echo "== 3. a rewrite that also drops the include: signed AXFR refused until restore" +rewrite_acl +python3 - "$CONF" <<'PY' +import sys; p=sys.argv[1]; s=open(p).read() +open(p,"w").write("".join(l for l in s.splitlines(True) if "seconddns-tsig" not in l)) +PY +kill -HUP "$(cat "$TMP/named.pid")"; sleep 1 +transfers 127.0.0.1 "$KEY" && fail "signed AXFR passed with the key unknown to named" || ok "signed AXFR refused while named does not know the key (BADKEY)" +"$TSIG" restore > "$TMP/out" 2>&1 || fail "restore: $(cat "$TMP/out")" +transfers 127.0.0.1 "$KEY" && ok "signed AXFR works again after restore" || fail "signed AXFR still refused after restore" + +echo "== 4. disable --zones" +"$TSIG" disable --zones > "$TMP/out" 2>&1 || fail "disable: $(cat "$TMP/out")" +transfers 127.0.0.1 && ok "our IP transfers without a key again" || fail "our IP refused after disable" + +echo +echo "passed: $PASS, failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/plesk/README.md b/hosting-panels/plesk/README.md index 7bf27e8..a638b9a 100644 --- a/hosting-panels/plesk/README.md +++ b/hosting-panels/plesk/README.md @@ -86,6 +86,26 @@ also-notify { SECONDARY_IP; }; The uninstaller does not remove the addresses from the Transfer Restrictions Template (`server_dns` has no command for it): remove them there by hand. +## TSIG-signed transfers + +Instead of our IP address, zone transfers can be authenticated with a TSIG key. Create the key in the SecondDNS dashboard (**Settings → DNS**), then either answer **2** at the installer prompt or pass it directly: + +```bash +install.sh --api-key=YOUR_API_KEY --tsig=KEY:SECRET # KEY = label or full name +seconddns-tsig enable --tsig=KEY:SECRET # on an existing install +``` + +What `seconddns-tsig enable` does on Plesk: + +1. Writes the key to `/var/named/chroot/etc/seconddns-tsig.key` (0640, named's group) and includes it as `/etc/seconddns-tsig.key` in the custom directives block of `named.conf`, which Plesk keeps. +2. Adds `key "";` to `acl common-allow-transfer`, the list every Plesk zone uses, next to our IPs. +3. Sets the key on this server's zones in SecondDNS. Only then our IPs leave the ACL, so no transfer is ever refused. +4. Installs `seconddns-tsig-restore.path` (watches `named.conf`) and `seconddns-tsig-restore.timer` (every 5 minutes). + +Plesk rewrites `acl common-allow-transfer` from the Transfer Restrictions Template whenever it rebuilds the DNS configuration. That drops the key from the ACL and brings our IPs back. The path unit runs `seconddns-tsig restore` within seconds and puts it right. In between, SecondDNS's signed requests still pass: named keeps knowing the key through the include in the custom directives block, and our IP is back in the ACL. If Plesk ever rewrites the custom block as well, named no longer knows the key and signed transfers are refused (BADKEY) until `restore` runs; the secondary retries on its schedule. To keep our IPs out of the template for good, remove them in **Tools & Settings → DNS Settings → Transfer Restrictions Template**. + +`seconddns-tsig status` shows the key in use; `seconddns-tsig disable --zones` goes back to transfers by IP (the uninstaller runs it first). + ## DNS Template The installer automatically: diff --git a/hosting-panels/plesk/install.sh b/hosting-panels/plesk/install.sh index 8e0adeb..2e5c7ce 100755 --- a/hosting-panels/plesk/install.sh +++ b/hosting-panels/plesk/install.sh @@ -26,6 +26,7 @@ for arg in "$@"; do --api-url=*) API_URL="${arg#*=}" ;; --master-ip=*) MASTER_IP="${arg#*=}" ;; --yes|-y) AUTO_YES=1 ;; + --tsig=*) TSIG="${arg#*=}" ;; --help|-h) echo "Usage: $0 --api-key=KEY [--api-url=URL] [--master-ip=IP] [--yes]" echo "" @@ -33,6 +34,7 @@ for arg in "$@"; do echo " --api-url=URL API base URL (default: https://seconddns.com)" echo " --master-ip=IP Primary DNS server IP (default: auto-detect)" echo " --yes Skip confirmation prompts" + echo " --tsig=[ALG:]KEY:SECRET Zone transfers by TSIG key instead of our IP" exit 0 ;; esac @@ -423,6 +425,11 @@ else echo -e " \033[1;33mThen click Apply.\033[0m" fi +echo "" +# before the initial sync, so zones are created with the key when one is chosen +SECONDDNS_AUTO_YES="$AUTO_YES" /usr/local/bin/seconddns-tsig setup ${TSIG:+--tsig="$TSIG"} || \ + echo "[!] TSIG not enabled; zone transfers stay by IP. Later: seconddns-tsig enable --tsig=[ALG:]KEY:SECRET" + echo "" # reconcile already knows where each panel keeps its zone list; --add-missing # only, since removing a zone the panel lacks is a separate decision.