diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c1669a..f193586 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,6 +44,19 @@ jobs: - name: test_tsig.sh run: bash hosting-panels/common/tests/test_tsig.sh + - name: test_tsig_bind.sh + run: bash hosting-panels/common/tests/test_tsig_bind.sh + + # a real named: the apparmor profile would keep it out of the test's temp dir + - name: test_tsig_bind_named.sh + run: | + sudo apt-get install -y -qq bind9 bind9-utils dnsutils + sudo apparmor_parser -R /etc/apparmor.d/usr.sbin.named 2>/dev/null || true + out=$(bash hosting-panels/common/tests/test_tsig_bind_named.sh); rc=$? + echo "$out" + echo "$out" | grep -q '^skipped' && { echo "::error::BIND missing on the runner"; exit 1; } + exit $rc + - name: test_valid_ip.sh run: bash hosting-panels/common/tests/test_valid_ip.sh diff --git a/hosting-panels/common/seconddns-tsig b/hosting-panels/common/seconddns-tsig index 99c572b..552cf8c 100755 --- a/hosting-panels/common/seconddns-tsig +++ b/hosting-panels/common/seconddns-tsig @@ -14,6 +14,18 @@ seconddns-tsig secret print the stored secret (root) seconddns-tsig clear remove the [tsig] section and the secret file + seconddns-tsig enable --tsig=[ALGORITHM:]KEY:SECRET [--zones|--no-zones] + resolve and store the key, install it on this server's BIND (key file, include, + allow-transfer by key instead of our IPs), then set it on this server's zones in + SecondDNS (asks unless --zones / --no-zones) + 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) + 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 + The config is world-readable (panel hooks read it as non-root users), so the secret lives in /etc/seconddns/tsig.secret (0600) and the config only names that file. It is kept so the key can be put back on the panel's DNS server when the panel rewrites its configuration. @@ -23,10 +35,14 @@ import argparse import configparser import os import re +import shlex +import shutil +import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) -from seconddns_common import load_config, resolve_tsig_key, check_tsig_secret, parse_tsig_pair # noqa: E402 +from seconddns_common import api, load_config, resolve_tsig_key, check_tsig_secret, parse_tsig_pair # noqa: E402 +import seconddns_bind as bind # noqa: E402 def write_section(path, body): @@ -60,6 +76,232 @@ def stored_secret_file(path): return cp.get("tsig", "secret_file", fallback="").strip() +def resolve_pair(config, pair): + """Key for a --tsig pair, checked against the account. Exits on any mismatch.""" + algorithm, ref, secret, err = parse_tsig_pair(pair) + if err: + sys.exit(err) + key, err = resolve_tsig_key(load_config(config), ref) + if err: + sys.exit(err) + if algorithm and algorithm != key["algorithm"]: + sys.exit(f"{key['name']} is {key['algorithm']}, not {algorithm}") + why = check_tsig_secret(key["algorithm"], secret) + if why: + sys.exit(f"{key['name']}: {why}") + return key, secret + + +def store(config, key, secret): + write_secret(secret) + write_section(config, f"key_id = {key['id']}\nkey_name = {key['name']}\nalgorithm = {key['algorithm']}\nsecret_file = {SECRET_FILE}\n") + + +# --- BIND ------------------------------------------------------------------------------ + +NAMED_OPTIONS_CANDIDATES = ["/etc/bind/named.conf.options", "/etc/named.conf.options", "/etc/named.conf", "/etc/bind/named.conf"] + + +def named_options(): + """The BIND options file, only when named is the DNS server actually running: a + named.conf left behind on a PowerDNS server must not be edited.""" + forced = os.environ.get("SECONDDNS_NAMED_OPTIONS") + if forced: + return forced if os.path.isfile(forced) else None + if subprocess.run(["pgrep", "-x", "named"], capture_output=True).returncode != 0: + return None + return next((f for f in NAMED_OPTIONS_CANDIDATES if os.path.isfile(f)), None) + + +def our_ips(cfg): + code, info = api(cfg, "GET", "/api/server-info", ua="SecondDNS-Tsig/1.0") + if code != 200 or not isinstance(info, dict): + sys.exit(f"GET /api/server-info failed: HTTP {code}") + 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")) + try: + return subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def reload_named(): + forced = os.environ.get("SECONDDNS_NAMED_RELOAD") + cmds = [shlex.split(forced)] if forced else [["rndc", "reload"], ["systemctl", "reload", "named"], ["systemctl", "reload", "bind9"]] + for cmd in cmds: + try: + if subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0: + return True + except (OSError, subprocess.TimeoutExpired): + continue + return False + + +def named_gid(): + """Group named runs as, read from the process: the options file may be root:root + while named runs as named or bind.""" + forced = os.environ.get("SECONDDNS_NAMED_GID") + if forced: + return int(forced) + r = subprocess.run(["pgrep", "-x", os.environ.get("SECONDDNS_NAMED_PROC", "named")], capture_output=True, text=True) + for pid in r.stdout.split(): + try: + with open(f"/proc/{pid}/status") as f: + for line in f: + if line.startswith("Gid:"): + return int(line.split()[1]) + except OSError: + continue + sys.exit("cannot determine the group named runs as; key file not written") + + +def write_key_file(path, key, secret): + """Key file readable by named only: mode 0640, root and named's group.""" + gid = named_gid() + tmp = path + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640) + with os.fdopen(fd, "w") as f: + f.write(bind.key_block(key["name"], key["algorithm"], secret)) + try: + os.chown(tmp, 0, gid) + except PermissionError: + os.chown(tmp, -1, gid) # unprivileged runs (tests): at least the group + os.chmod(tmp, 0o640) + os.replace(tmp, path) + + +def edit_named(options, change): + """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: + before = f.read() + after, err = change(before) + if err: + sys.exit(f"{options}: {err}") + if after == before: + return False + backup = options + ".seconddns-tsig.bak" + shutil.copy2(options, backup) + with open(options, "w") as f: + f.write(after) + if not checkconf_ok(): + 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 + # here would leave a config the next named restart cannot load + if not reload_named(): + shutil.copy2(backup, options) + reload_named() + sys.exit(f"{options}: named did not reload the change; restored from {backup}") + 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) + try: + edit_named(options, lambda t: bind.edit_allow_transfer(bind.add_include(t, key_file), add=[bind.key_entry(key["name"])])) + except SystemExit: + if not existed: + os.remove(key_file) + raise + return 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()) + removed = [x for x in before if x.strip('"') in set(ips)] + edit_named(options, lambda t: bind.edit_allow_transfer(t, 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 add_section_value(config, name, value): + cp = configparser.ConfigParser() + cp.read(config) + body = "".join(f"{k} = {v}\n" for k, v in cp["tsig"].items() if k != name) + 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 restart_worker(): + """seconddns-queued reads the config once; restart it so new zones follow [tsig].""" + forced = os.environ.get("SECONDDNS_QUEUED_RESTART") + cmd = shlex.split(forced) if forced else ["systemctl", "try-restart", "seconddns-queued"] + try: + subprocess.run(cmd, capture_output=True, timeout=60) + except (OSError, subprocess.TimeoutExpired): + print("[!] restart the queue worker: systemctl restart seconddns-queued", file=sys.stderr) + + +def tty_input(prompt, secret=False): + import getpass + try: + with open("/dev/tty", "r+") as tty: + if secret: + return getpass.getpass(prompt, stream=tty) + tty.write(prompt) + tty.flush() + return tty.readline().strip() + except OSError: + return None + + +# --- zones in SecondDNS ----------------------------------------------------------------- + +def this_servers_zones(cfg): + code, zones = api(cfg, "GET", "/api/zones", ua="SecondDNS-Tsig/1.0") + if code != 200 or not isinstance(zones, list): + sys.exit(f"GET /api/zones failed: HTTP {code}") + return [z for z in zones if z.get("masterIp") == cfg["master_ip"]] + + +def ask(question, answer): + if answer is not None: + return answer + if not sys.stdin.isatty(): + return False + return input(f"{question} [y/N] ").strip().lower() in ("y", "yes") + + +def set_zone_keys(cfg, zones, key_id, answer, what): + """True when every zone ends up with key_id.""" + todo = [z for z in zones if z.get("tsigKeyId") != key_id] + if not todo: + return True + if not ask(f"{what} on {len(todo)} zone(s) of this server in SecondDNS?", answer): + print(f"[=] {len(todo)} zone(s) left as they are") + return False + failed = 0 + for z in todo: + code, _ = api(cfg, "PATCH", f"/api/zones/{z['id']}", {"tsigKeyId": key_id}, ua="SecondDNS-Tsig/1.0") + if code != 200: + failed += 1 + print(f"[!] {z['name']}: HTTP {code}", file=sys.stderr) + print(f"[+] {what}: {len(todo) - failed} of {len(todo)} zone(s)") + return failed == 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")) @@ -73,8 +315,103 @@ def main(): sub.add_parser("secret") sub.add_parser("status") sub.add_parser("clear") + for name in ("enable", "disable"): + x = sub.add_parser(name) + if name == "enable": + x.add_argument("--tsig", metavar="[ALGORITHM:]KEY:SECRET", required=True) + g = x.add_mutually_exclusive_group() + g.add_argument("--zones", dest="zones", action="store_true", default=None) + g.add_argument("--no-zones", dest="zones", action="store_false") + sub.add_parser("restore") + su = sub.add_parser("setup") + su.add_argument("--tsig", metavar="[ALGORITHM:]KEY:SECRET") a = ap.parse_args() + if a.cmd == "setup": + if not named_options(): + if a.tsig: + sys.exit("--tsig: BIND (named) is not running here; TSIG for PowerDNS panels is not supported yet") + return + pair = a.tsig + if not pair and os.environ.get("SECONDDNS_AUTO_YES") != "1": + choice = tty_input("How should SecondDNS fetch your zones?\n" + " 1) by our IP address in allow-transfer [default]\n" + " 2) with a TSIG key (create one in Settings › DNS first)\n" + "Choice [1]: ") + if choice == "2": + pair = tty_input("TSIG key as [ALGORITHM:]KEY:SECRET (KEY = label or full name from Settings › DNS): ", secret=True) + if not pair: + sys.exit("no key given; transfers stay by IP. Later: seconddns-tsig enable --tsig=[ALGORITHM:]KEY:SECRET") + if not pair: + print("[=] zone transfers: by our IP address") + return + a.cmd, a.tsig, a.zones = "enable", pair, True + + if a.cmd in ("enable", "disable", "restore"): + options = named_options() + if not options: + sys.exit("BIND (named) is not running here; TSIG for PowerDNS panels is not supported yet") + cfg = load_config(a.config) + cp = configparser.ConfigParser() + cp.read(a.config) + if a.cmd == "enable": + # 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) + 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']}\"; }};") + 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) + if removed: + add_section_value(a.config, "removed_entries", ",".join(removed)) + print("[+] allow-transfer: 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") + elif a.cmd == "disable": + if not cp.has_section("tsig"): + print("TSIG: already off") + return + t = cp["tsig"] + saved = dict(t) + # 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) + 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() + sys.exit("[!] the key stays on BIND while zones still use it: seconddns-tsig disable --zones") + remove_key_from_bind(saved["key_name"], options) + 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") + else: + if not cp.has_section("tsig"): + return + t = cp["tsig"] + key_file = key_file_for(options) + with open(options) 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"]): + return + with open(t["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}") + return + if a.cmd == "check-secret": why = check_tsig_secret(a.algorithm, a.secret) if why: @@ -113,20 +450,9 @@ def main(): os.remove(f) return - algorithm, ref, secret, err = parse_tsig_pair(a.tsig) - if err: - sys.exit(err) - key, err = resolve_tsig_key(load_config(a.config), ref) - if err: - sys.exit(err) - if algorithm and algorithm != key["algorithm"]: - sys.exit(f"{key['name']} is {key['algorithm']}, not {algorithm}") - why = check_tsig_secret(key["algorithm"], secret) - if why: - sys.exit(f"{key['name']}: {why}") + key, secret = resolve_pair(a.config, a.tsig) if a.write: - write_secret(secret) - write_section(a.config, f"key_id = {key['id']}\nkey_name = {key['name']}\nalgorithm = {key['algorithm']}\nsecret_file = {SECRET_FILE}\n") + store(a.config, key, secret) print(key["id"], key["name"], key["algorithm"]) diff --git a/hosting-panels/common/seconddns_bind.py b/hosting-panels/common/seconddns_bind.py new file mode 100644 index 0000000..7097b89 --- /dev/null +++ b/hosting-panels/common/seconddns_bind.py @@ -0,0 +1,166 @@ +# 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 +"""BIND side of TSIG: key file, include and allow-transfer in the options block. + +Only text edits; seconddns-tsig does the backup, named-checkconf, reload and rollback. +""" +import re + +KEY_FILE_NAME = "seconddns-tsig.key" +MARK = "// seconddns-tsig" + + +def _code_mask(text, keep_strings=False): + """Same length as text, with comments (and strings, unless keep_strings) blanked, so + braces and keywords are only matched in real configuration.""" + out = list(text) + i, n = 0, len(text) + while i < n: + c = text[i] + if c == '"': + j = text.find('"', i + 1) + j = n if j < 0 else j + if not keep_strings: + for k in range(i + 1, j): + out[k] = " " + i = j + 1 + elif text.startswith("//", i) or c == "#": + j = text.find("\n", i) + j = n if j < 0 else j + for k in range(i, j): + out[k] = " " + i = j + elif text.startswith("/*", i): + j = text.find("*/", i + 2) + j = n if j < 0 else j + 2 + for k in range(i, j): + if text[k] != "\n": + out[k] = " " + i = j + else: + i += 1 + return "".join(out) + + +def _options_span(text, mask): + """(start, end) of the inside of the top-level options { ... } block, or None.""" + depth = 0 + for m in re.finditer(r"[{}]|\boptions\s*\{", mask): + tok = m.group(0) + if tok.startswith("options") and depth == 0: + start = m.end() + d = 1 + for k in range(start, len(mask)): + if mask[k] == "{": + d += 1 + elif mask[k] == "}": + d -= 1 + if d == 0: + return start, k + return None + depth += 1 if tok == "{" else -1 if tok == "}" else 0 + return None + + +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) + 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 + entries, pos = [], 0 + for i, ch in enumerate(body_mask): + if ch == ";": + entries.append((pos, i + 1, values[pos:i].strip())) + pos = i + 1 + tail = body[pos:] + gone = {d.strip('"') for d in drop} + present = [v for _, _, v in entries if v.strip('"') not in gone] + added = [x for x in add if x not in present] + if len(present) + len(added) > 1: + 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"; ' + 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()), " ") + first, nl, rest = tail.partition("\n") + 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 + + +def key_entry(name): + return f'key "{name}"' + + +def has_key_in_allow_transfer(text, key_name): + mask = _code_mask(text) + span = _options_span(text, mask) + if not span: + return False + s, e = span + m = re.search(r"\ballow-transfer\s*\{([^{}]*)\}\s*;", mask[s:e]) + return bool(m) and f'key "{key_name}"' in text[s + m.start(1):s + m.end(1)] + + +def include_line(key_file): + return f'include "{key_file}"; {MARK}\n' + + +def add_include(text, key_file): + if include_line(key_file).strip() in text: + return text + return text.rstrip("\n") + "\n" + include_line(key_file) + + +def remove_include(text, key_file): + return "".join(l for l in text.splitlines(True) if l.strip() != include_line(key_file).strip()) + + +def zone_level_transfers(text, ips): + """allow-transfer statements outside the options block that name one of our IPs: + zone-level entries (cPanel's WHM "Additional zone configuration") override options.""" + mask = _code_mask(text) + span = _options_span(text, mask) + found = 0 + for m in re.finditer(r"\ballow-transfer\s*\{([^{}]*)\}\s*;", mask): + if span and span[0] <= m.start() < span[1]: + continue + body = _code_mask(text, keep_strings=True)[m.start(1):m.end(1)] + if any(ip in [x.strip().strip('"') for x in body.split(";")] for ip in ips): + found += 1 + return found + + +def allow_transfer_entries(text): + """Entries of the options-level allow-transfer, as written (quotes kept), or [].""" + mask = _code_mask(text) + span = _options_span(text, mask) + if not span: + return [] + s, e = span + m = re.search(r"\ballow-transfer\s*\{([^{}]*)\}\s*;", mask[s:e]) + if not m: + 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()] diff --git a/hosting-panels/common/tests/test_named_conf_edit.sh b/hosting-panels/common/tests/test_named_conf_edit.sh index e71ee26..7d9639a 100755 --- a/hosting-panels/common/tests/test_named_conf_edit.sh +++ b/hosting-panels/common/tests/test_named_conf_edit.sh @@ -53,7 +53,12 @@ for panel in cpanel plesk directadmin; do && ok "braces balanced ($opened)" || fail "braces unbalanced: $opened open, $closed closed" if command -v named-checkconf >/dev/null 2>&1; then - named-checkconf "$f" >/dev/null 2>&1 && ok "named-checkconf accepts it" || fail "named-checkconf rejects it" + # the fixture names /var/named and an include from a real cPanel host, which the + # runner lacks; point those paths at a scratch copy (no chroot: CI is not root) + root="$TMP/root-$panel" + mkdir -p "$root/var/named" && touch "$root/var/named/named.rfc1912.zones" + sed "s|\"/var/named|\"$root/var/named|g" "$f" > "$root/named.conf" + named-checkconf "$root/named.conf" >/dev/null 2>&1 && ok "named-checkconf accepts it" || fail "named-checkconf rejects it: $(named-checkconf "$root/named.conf" 2>&1 | head -1)" fi done diff --git a/hosting-panels/common/tests/test_tsig_bind.sh b/hosting-panels/common/tests/test_tsig_bind.sh new file mode 100755 index 0000000..3ff843f --- /dev/null +++ b/hosting-panels/common/tests/test_tsig_bind.sh @@ -0,0 +1,236 @@ +#!/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 enable / disable / restore on BIND, with a mock API and a stand-in for +# named-checkconf and rndc. The real named run is test_tsig_bind_named.sh. +# Requires: bash, python3. No network. +# +# Usage: ./test_tsig_bind.sh + +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +TSIG="$HERE/../seconddns-tsig" +FIXTURE="$HERE/fixtures/cpanel-named.conf" +TMP="$(mktemp -d)" +PORT=8896 +OUR="203.0.113.9" + +export SECONDDNS_CONF="$TMP/seconddns.conf" +export SECONDDNS_TSIG_SECRET="$TMP/etc-seconddns/tsig.secret" +export SECONDDNS_NAMED_OPTIONS="$TMP/named/named.conf" +export SECONDDNS_NAMED_CHECKCONF="sh -c 'test ! -e $TMP/checkconf-fails'" +export SECONDDNS_NAMED_RELOAD="sh -c 'test ! -e $TMP/reload-fails'" +export SECONDDNS_NAMED_GID="$(id -g)" +export SECONDDNS_QUEUED_RESTART="sh -c 'echo restart >> $TMP/restarts.log'" +KEYFILE="$TMP/named/seconddns-tsig.key" + +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')"; } +at() { grep -E '^[[:space:]]*allow-transfer' "$SECONDDNS_NAMED_OPTIONS" | sed 's/^[[:space:]]*//'; } + +reset() { + mkdir -p "$TMP/named" + cp "$FIXTURE" "$SECONDDNS_NAMED_OPTIONS" + # what the installer leaves: our IP next to the customer's own secondary + sed 's/allow-transfer { "none"; };/allow-transfer { 198.51.100.7; 203.0.113.9; };/' "$FIXTURE" > "$SECONDDNS_NAMED_OPTIONS" + rm -f "$KEYFILE" "$TMP/checkconf-fails" "$TMP/reload-fails" "$TMP/patch-fails" "$SECONDDNS_TSIG_SECRET" + : > "$TMP/patches.log"; : > "$TMP/restarts.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" + +# Mock API: keys, server-info, zones; PATCH /api/zones/ updates $TMP/zones and is +# logged; $TMP/patch-fails makes the PATCH of z2 answer 500. +python3 - "$PORT" "$TMP" > /dev/null 2>&1 <<'PYEOF' & +import http.server, sys, json, os +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,2001:db8::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'{}') + zid = self.path.rsplit('/', 1)[1] + if zid == 'z2' and os.path.exists(tmp+'/patch-fails'): + self.reply(500, {}); return + zones = json.load(open(tmp+'/zones')) + for z in zones: + if z['id'] == zid: z['tsigKeyId'] = body.get('tsigKeyId') + json.dump(zones, open(tmp+'/zones','w')) + with open(tmp+'/patches.log','a') as f: f.write(f"{zid} {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. allow-transfer edits on the real cPanel named.conf" +out=$(python3 - "$HERE/.." "$FIXTURE" <<'PY' +import sys; sys.path.insert(0, sys.argv[1]); import seconddns_bind as b +t = open(sys.argv[2]).read() +k = b.key_entry("acct-web") +n, _ = b.edit_allow_transfer(t, add=[k]) +print([l.strip() for l in n.splitlines() if "allow-transfer" in l and not l.strip().startswith("//")][0]) +n2, _ = b.edit_allow_transfer(n, add=[k]) +print("idempotent" if n2 == n else "changed twice") +multi = t.replace('allow-transfer { "none"; };', 'allow-transfer {\n 203.0.113.9;\n 10.0.0.0/8; // office\n };') +m, _ = b.edit_allow_transfer(multi, add=[k], drop=["203.0.113.9"]) +print(repr(m[m.index("allow-transfer"):m.index("};", m.index("allow-transfer"))+2])) +decoy = "// options { allow-transfer { 1.2.3.4; }; }\n/* allow-transfer { 5.6.7.8; }; */\n" + t +d, _ = b.edit_allow_transfer(decoy, add=[k]) +print("comments untouched" if d.startswith("// options { allow-transfer { 1.2.3.4; }; }\n/* allow-transfer { 5.6.7.8; }; */") else "comment edited") +print(b.edit_allow_transfer("logging { };\n", add=[k])[1]) +PY +) +assert_eq "$(sed -n 1p <<<"$out")" 'allow-transfer { key "acct-web"; };' '"none" replaced by the key' +assert_eq "$(sed -n 2p <<<"$out")" "idempotent" "adding the key twice changes nothing" +assert_eq "$(sed -n 3p <<<"$out")" "'allow-transfer {\\n 10.0.0.0/8; // office\\n key \"acct-web\";\\n };'" "multi-line list: our IP dropped, comment kept, key on its own line" +assert_eq "$(sed -n 4p <<<"$out")" "comments untouched" "allow-transfer inside comments is not edited" +assert_eq "$(sed -n 5p <<<"$out")" "no options { } block" "config without options is refused" + +echo "== 2. enable --no-zones: key accepted next to our IP, zones untouched" +reset +"$TSIG" enable --tsig="web:$SECRET" --no-zones > "$TMP/out" 2>&1; rc=$? +assert_eq "$rc" 0 "enable exits 0" +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; 203.0.113.9; key "acct-web"; };' "our IP kept while zones are unsigned" +grep -q "include \"$KEYFILE\"; // seconddns-tsig" "$SECONDDNS_NAMED_OPTIONS" && ok "key file included" || fail "include missing" +grep -q "secret \"$SECRET\";" "$KEYFILE" && ok "key file holds the secret" || fail "key file content" +assert_eq "$(stat -c %a "$KEYFILE" 2>/dev/null || stat -f %Lp "$KEYFILE")" 640 "key file is 0640" +assert_eq "$(wc -l < "$TMP/patches.log" | tr -d ' ')" 0 "no zone patched" +grep -q "our IPs stay in allow-transfer" "$TMP/out" && ok "says why our IP stays" || fail "no hint about our IP" + +echo "== 3. enable --zones: this server's zones get the key, then our IP goes" +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +assert_eq "$(sort "$TMP/patches.log" | tr '\n' ' ')" "z1 k1 z2 k1 " "only zones mastered here patched" +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; key "acct-web"; };' "our IP removed, customer's secondary kept" + +echo "== 4. a zone PATCH fails: our IP stays" +reset +touch "$TMP/patch-fails" +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; 203.0.113.9; key "acct-web"; };' "IP kept while a zone is still unsigned" + +echo "== 5. named-checkconf rejects: nothing kept" +reset +cp "$SECONDDNS_NAMED_OPTIONS" "$TMP/before" +touch "$TMP/checkconf-fails" +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 && fail "enable succeeded with a rejected config" || ok "enable fails" +cmp -s "$TMP/before" "$SECONDDNS_NAMED_OPTIONS" && ok "named.conf restored" || fail "named.conf left edited" +[ -e "$KEYFILE" ] && fail "key file left behind" || ok "key file removed" +grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "[tsig] stored after a failed enable" || ok "nothing stored" +assert_eq "$(wc -l < "$TMP/patches.log" | tr -d ' ')" 0 "no zone patched" + +echo "== 6. restore after the panel rewrote named.conf" +reset +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +cp "$FIXTURE" "$SECONDDNS_NAMED_OPTIONS"; rm -f "$KEYFILE" # panel regenerated its config +"$TSIG" restore > "$TMP/out" 2>&1 +assert_eq "$(at)" 'allow-transfer { key "acct-web"; };' "key back in allow-transfer" +[ -e "$KEYFILE" ] && grep -q "include \"$KEYFILE\"" "$SECONDDNS_NAMED_OPTIONS" && ok "key file and include back" || fail "restore incomplete" +cp "$SECONDDNS_NAMED_OPTIONS" "$TMP/after" +"$TSIG" restore > "$TMP/out" 2>&1 +cmp -s "$TMP/after" "$SECONDDNS_NAMED_OPTIONS" && [ ! -s "$TMP/out" ] && ok "second restore is a quiet no-op" || fail "second restore changed something" + +echo "== 7. disable --no-zones while zones use the key: refused, IP back, key kept" +"$TSIG" disable --no-zones > /dev/null 2>&1 && fail "disable succeeded with signed zones left" || ok "disable refuses" +assert_eq "$(at)" 'allow-transfer { key "acct-web"; 203.0.113.9; };' "our IP back, key still accepted" +[ -e "$KEYFILE" ] && ok "key file kept" || fail "key file removed too early" + +echo "== 8. disable --zones: zones unsigned, key and include gone" +: > "$TMP/patches.log" +"$TSIG" disable --zones > /dev/null 2>&1; rc=$? +assert_eq "$rc" 0 "disable exits 0" +assert_eq "$(sort "$TMP/patches.log" | tr '\n' ' ')" "z1 None z2 None " "zones set back to unsigned" +assert_eq "$(at)" 'allow-transfer { 203.0.113.9; };' "only our IP left" +grep -q "seconddns-tsig" "$SECONDDNS_NAMED_OPTIONS" && fail "include left" || ok "include removed" +[ -e "$KEYFILE" ] && fail "key file left" || ok "key file removed" +grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "[tsig] left" || ok "[tsig] removed" + +echo "== 9. the queue worker is restarted when [tsig] changes" +reset +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +assert_eq "$(wc -l < "$TMP/restarts.log" | tr -d ' ')" 1 "enable restarts seconddns-queued once" +"$TSIG" disable --zones > /dev/null 2>&1 +assert_eq "$(wc -l < "$TMP/restarts.log" | tr -d ' ')" 2 "disable restarts it again" + +echo "== 10. a failed disable keeps [tsig], so it can be rerun" +reset +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +touch "$TMP/patch-fails" +"$TSIG" disable --zones > /dev/null 2>&1 && fail "disable succeeded with a zone still signed" || ok "disable fails when a zone keeps the key" +grep -q '^key_id = k1$' "$SECONDDNS_CONF" && ok "[tsig] put back" || fail "[tsig] lost after a failed disable" +[ -e "$KEYFILE" ] && ok "key still on BIND for the signed zone" || fail "key removed under a signed zone" +rm -f "$TMP/patch-fails" +"$TSIG" disable --zones > /dev/null 2>&1 && ok "rerun of disable completes" || fail "rerun of disable failed" + +echo "== 11. zone-level allow-transfer naming our IP is reported" +reset +printf 'zone "a.example" { type master; file "a.db"; allow-transfer { 203.0.113.9; }; };\n' >> "$SECONDDNS_NAMED_OPTIONS" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 +grep -q "1 zone-level allow-transfer entry name our IP" "$TMP/out" && ok "zone-level entry counted" || fail "no warning: $(cat "$TMP/out")" +grep -q 'allow-transfer { key "acct-web"; };' "$TMP/out" && ok "WHM line printed with the key" || fail "no WHM line" + +echo "== 12. setup: --tsig enables, no terminal or --yes keeps our IP" +reset +SECONDDNS_AUTO_YES=1 "$TSIG" setup < /dev/null > "$TMP/out" 2>&1 +grep -q "by our IP address" "$TMP/out" && ok "--yes without --tsig keeps IP" || fail "setup --yes: $(cat "$TMP/out")" +grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "[tsig] written without a choice" || ok "nothing written" +"$TSIG" setup --tsig="web:$SECRET" > /dev/null 2>&1 +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; key "acct-web"; };' "setup --tsig enables and sets the zones" +SECONDDNS_NAMED_OPTIONS="$TMP/absent.conf" "$TSIG" setup --tsig="web:$SECRET" > /dev/null 2>&1 && fail "--tsig accepted without BIND" || ok "--tsig without BIND is refused" + +echo "== 13. disable gives back exactly what enable removed (IPv6-first host)" +reset +sed 's/allow-transfer { "none"; };/allow-transfer { 198.51.100.7; 2001:db8::9; };/' "$FIXTURE" > "$SECONDDNS_NAMED_OPTIONS" +"$TSIG" enable --tsig="web:$SECRET" --zones > /dev/null 2>&1 +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; key "acct-web"; };' "IPv6 entry removed" +grep -q '^removed_entries = 2001:db8::9$' "$SECONDDNS_CONF" && ok "removed entry remembered" || fail "removed entry not recorded: $(grep removed "$SECONDDNS_CONF")" +"$TSIG" disable --zones > /dev/null 2>&1 +assert_eq "$(at)" 'allow-transfer { 198.51.100.7; 2001:db8::9; };' "disable puts the IPv6 entry back, not the first IPv4" + +echo "== 14. a failed reload restores the config and stops" +reset +cp "$SECONDDNS_NAMED_OPTIONS" "$TMP/before" +touch "$TMP/reload-fails" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 && fail "enable succeeded although named did not reload" || ok "enable fails on a failed reload" +cmp -s "$TMP/before" "$SECONDDNS_NAMED_OPTIONS" && ok "named.conf restored" || fail "named.conf left edited" +grep -q "did not reload" "$TMP/out" && ok "says named did not reload" || fail "no reload message: $(cat "$TMP/out")" +assert_eq "$(wc -l < "$TMP/patches.log" | tr -d ' ')" 0 "no zone patched" +grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "[tsig] stored after a failed reload" || ok "nothing stored" + +echo "== 15. key file group comes from named; unknown group refuses" +reset +"$TSIG" enable --tsig="web:$SECRET" --no-zones > /dev/null 2>&1 +assert_eq "$(stat -c %g "$KEYFILE" 2>/dev/null || stat -f %g "$KEYFILE")" "$(id -g)" "key file group is named's group" +"$TSIG" disable --zones > /dev/null 2>&1 +reset +env -u SECONDDNS_NAMED_GID SECONDDNS_NAMED_PROC=no-such-named-process "$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 && fail "enable without a known named group succeeded" || ok "enable refuses without named's group" +grep -q "cannot determine the group named runs as" "$TMP/out" && ok "says why" || fail "message: $(cat "$TMP/out")" +[ -e "$KEYFILE" ] && fail "key file written" || ok "no key file" + +echo +echo "passed: $PASS, failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/common/tests/test_tsig_bind_named.sh b/hosting-panels/common/tests/test_tsig_bind_named.sh new file mode 100755 index 0000000..6a87b57 --- /dev/null +++ b/hosting-panels/common/tests/test_tsig_bind_named.sh @@ -0,0 +1,124 @@ +#!/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 against a real named: after enable, AXFR from our IP needs the key while +# the customer's other secondary still transfers by IP; after disable our IP works again. +# Requires: named, named-checkconf, dig, python3. Skips itself when BIND is not installed. +# +# Usage: ./test_tsig_bind_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=8895 +DNS_PORT=5300 +CONF="$TMP/named.conf" + +export SECONDDNS_CONF="$TMP/seconddns.conf" +export SECONDDNS_TSIG_SECRET="$TMP/etc-seconddns/tsig.secret" +export SECONDDNS_NAMED_OPTIONS="$CONF" +export SECONDDNS_NAMED_CHECKCONF="named-checkconf $CONF" +export SECONDDNS_NAMED_RELOAD="sh -c 'kill -HUP \$(cat $TMP/named.pid) && sleep 1'" +export SECONDDNS_QUEUED_RESTART="true" +# the runner may also run the distribution's named as bind; ours runs as us +export SECONDDNS_NAMED_GID="$(id -g)" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +cat > "$TMP/example.test.db" <<'EOF' +$TTL 300 +@ IN SOA ns1.example.test. hostmaster.example.test. 1 300 60 86400 60 +@ IN NS ns1.example.test. +ns1 IN A 192.0.2.53 +www IN A 192.0.2.10 +EOF +cat > "$CONF" < "$SECONDDNS_CONF" < /dev/null 2>&1 <<'PYEOF' & +import http.server, sys, json +port, tmp = int(sys.argv[1]), sys.argv[2] +zones = [{"id": "z1", "name": "example.test", "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', port), 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 example.test SOA > /dev/null 2>&1 && break; sleep 0.5; done + +axfr() { # $1 source address, $2 optional -y key spec + dig +tries=1 +time=3 -p $DNS_PORT @127.0.0.1 -b "$1" ${2:+-y "$2"} example.test AXFR 2>&1 +} +transfers() { axfr "$@" | grep -q 'www.example.test.*192.0.2.10'; } + +echo "== 0. before: both secondaries transfer by IP" +transfers 127.0.0.1 && ok "our IP transfers without a key" || fail "baseline broken: $(axfr 127.0.0.1 | tail -2)" + +echo "== 1. enable --zones on a real named" +"$TSIG" enable --tsig="web:$SECRET" --zones > "$TMP/out" 2>&1 || fail "enable failed: $(cat "$TMP/out")" +named-checkconf "$CONF" && ok "named-checkconf accepts the result" || fail "named-checkconf rejects the result" +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 "hmac-sha256:acct-web:$SECRET" && ok "our IP with the key transfers" || fail "signed AXFR failed: $(axfr 127.0.0.1 "hmac-sha256:acct-web:$SECRET" | tail -2)" +OTHER=$(python3 -c 'import base64,os;print(base64.b64encode(os.urandom(32)).decode())') +transfers 127.0.0.1 "hmac-sha256:acct-web:$OTHER" && fail "a wrong secret transfers" || ok "a wrong secret is refused" +transfers 127.0.0.2 && ok "the customer's other secondary still transfers by IP" || fail "the customer's secondary lost access" + +echo "== 2. restore after the key file and include were lost" +rm -f "$TMP/seconddns-tsig.key" +sed -i.bak '/seconddns-tsig/d' "$CONF" +"$TSIG" restore > /dev/null 2>&1 +transfers 127.0.0.1 "hmac-sha256:acct-web:$SECRET" && ok "restore brings signed transfers back" || fail "restore did not work" + +echo "== 3. disable --zones" +"$TSIG" disable --zones > "$TMP/out" 2>&1 || fail "disable failed: $(cat "$TMP/out")" +transfers 127.0.0.1 && ok "our IP transfers without a key again" || fail "our IP still refused after disable" +[ -e "$TMP/seconddns-tsig.key" ] && fail "key file left" || ok "key file removed" + +echo +echo "passed: $PASS, failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/hosting-panels/cpanel/install.sh b/hosting-panels/cpanel/install.sh index a43e187..e592af8 100755 --- a/hosting-panels/cpanel/install.sh +++ b/hosting-panels/cpanel/install.sh @@ -27,6 +27,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 "" @@ -34,6 +35,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 @@ -273,6 +275,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns 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 /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 curl -sf --max-time 10 -o /usr/local/bin/seconddns-tsig "$COMMON_URL/seconddns-tsig?t=$(date +%s)" && chmod +x /usr/local/bin/seconddns-tsig +curl -sf --max-time 10 -o /usr/local/bin/seconddns_bind.py "$COMMON_URL/seconddns_bind.py?t=$(date +%s)" 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)") @@ -559,6 +562,11 @@ if [ -n "$API_NS" ]; then 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. diff --git a/hosting-panels/cpanel/uninstall.sh b/hosting-panels/cpanel/uninstall.sh index 482d471..e9e5e67 100755 --- a/hosting-panels/cpanel/uninstall.sh +++ b/hosting-panels/cpanel/uninstall.sh @@ -35,6 +35,9 @@ if ! confirm "Remove SecondDNS cPanel integration?"; then exit 0 fi +# TSIG off first: our IP back in allow-transfer, zones unsigned, key removed, while the config and the tool still exist +[ -x /usr/local/bin/seconddns-tsig ] && /usr/local/bin/seconddns-tsig disable --zones || true + # Remove hooks if [ -x "$HOOKS_BIN" ]; then echo "[*] Removing hooks..." @@ -106,7 +109,7 @@ 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 /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-tsig /etc/seconddns/tsig.secret +rm -f /usr/local/bin/seconddns-tsig /usr/local/bin/seconddns_bind.py /etc/seconddns/tsig.secret rmdir /etc/seconddns 2>/dev/null || true 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) diff --git a/hosting-panels/cyberpanel/install.sh b/hosting-panels/cyberpanel/install.sh index b0f3e95..6eb8b4d 100755 --- a/hosting-panels/cyberpanel/install.sh +++ b/hosting-panels/cyberpanel/install.sh @@ -282,6 +282,7 @@ 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 /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 cp "$COMMON_SRC/seconddns-tsig" /usr/local/bin/seconddns-tsig && chmod +x /usr/local/bin/seconddns-tsig +cp "$COMMON_SRC/seconddns_bind.py" /usr/local/bin/seconddns_bind.py bash "$COMMON_SRC/install-idn2.sh" mkdir -p /var/lib/seconddns bash "$COMMON_SRC/install-sqlite.sh" diff --git a/hosting-panels/cyberpanel/uninstall.sh b/hosting-panels/cyberpanel/uninstall.sh index ca24855..ba27e67 100755 --- a/hosting-panels/cyberpanel/uninstall.sh +++ b/hosting-panels/cyberpanel/uninstall.sh @@ -37,6 +37,9 @@ if ! confirm "Remove SecondDNS integration?"; then exit 0 fi +# TSIG off first: our IP back in allow-transfer, zones unsigned, key removed, while the config and the tool still exist +[ -x /usr/local/bin/seconddns-tsig ] && /usr/local/bin/seconddns-tsig disable --zones || true + # Remove signal blocks from all CyberPanel files for f in "$WSGI_FILE" "$INIT_FILE" "$READY_FILE"; do [ -f "$f" ] || continue @@ -93,7 +96,7 @@ 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 /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-tsig /etc/seconddns/tsig.secret +rm -f /usr/local/bin/seconddns-tsig /usr/local/bin/seconddns_bind.py /etc/seconddns/tsig.secret rmdir /etc/seconddns 2>/dev/null || true 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) diff --git a/hosting-panels/directadmin/install.sh b/hosting-panels/directadmin/install.sh index 59b5d75..351be04 100644 --- a/hosting-panels/directadmin/install.sh +++ b/hosting-panels/directadmin/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 @@ -270,6 +272,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns 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 /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 curl -sf --max-time 10 -o /usr/local/bin/seconddns-tsig "$COMMON_URL/seconddns-tsig?t=$(date +%s)" && chmod +x /usr/local/bin/seconddns-tsig +curl -sf --max-time 10 -o /usr/local/bin/seconddns_bind.py "$COMMON_URL/seconddns_bind.py?t=$(date +%s)" 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)") @@ -461,6 +464,11 @@ if [ -n "$DNS_IPS" ]; then fi 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. diff --git a/hosting-panels/directadmin/uninstall.sh b/hosting-panels/directadmin/uninstall.sh index 0e5f9e3..088d8b2 100644 --- a/hosting-panels/directadmin/uninstall.sh +++ b/hosting-panels/directadmin/uninstall.sh @@ -13,6 +13,9 @@ LOG_FILE="/var/log/seconddns.log" echo "=== SecondDNS DirectAdmin Uninstaller ===" echo "" +# TSIG off first: our IP back in allow-transfer, zones unsigned, key removed, while the config and the tool still exist +[ -x /usr/local/bin/seconddns-tsig ] && /usr/local/bin/seconddns-tsig disable --zones || true + # Remove hooks for hook in dns_create_post.sh dns_delete_post.sh domain_change_post.sh; do if [ -f "$HOOKS_DIR/$hook" ] && grep -q "seconddns" "$HOOKS_DIR/$hook" 2>/dev/null; then @@ -37,7 +40,7 @@ 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 /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-tsig /etc/seconddns/tsig.secret +rm -f /usr/local/bin/seconddns-tsig /usr/local/bin/seconddns_bind.py /etc/seconddns/tsig.secret rmdir /etc/seconddns 2>/dev/null || true 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) diff --git a/hosting-panels/plesk/install.sh b/hosting-panels/plesk/install.sh index 249492c..6ec6e25 100755 --- a/hosting-panels/plesk/install.sh +++ b/hosting-panels/plesk/install.sh @@ -276,6 +276,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns 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 /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 curl -sf --max-time 10 -o /usr/local/bin/seconddns-tsig "$COMMON_URL/seconddns-tsig?t=$(date +%s)" && chmod +x /usr/local/bin/seconddns-tsig +curl -sf --max-time 10 -o /usr/local/bin/seconddns_bind.py "$COMMON_URL/seconddns_bind.py?t=$(date +%s)" 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 7b4b97d..8add921 100755 --- a/hosting-panels/plesk/uninstall.sh +++ b/hosting-panels/plesk/uninstall.sh @@ -22,6 +22,9 @@ confirm() { echo "=== SecondDNS Plesk Uninstaller ===" echo "" +# TSIG off first: our IP back in allow-transfer, zones unsigned, key removed, while the config and the tool still exist +[ -x /usr/local/bin/seconddns-tsig ] && /usr/local/bin/seconddns-tsig disable --zones || true + # Remove event handlers if command -v plesk &>/dev/null; then removed=0 @@ -127,7 +130,7 @@ 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 /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-tsig /etc/seconddns/tsig.secret +rm -f /usr/local/bin/seconddns-tsig /usr/local/bin/seconddns_bind.py /etc/seconddns/tsig.secret rmdir /etc/seconddns 2>/dev/null || true 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)