From 57579e0c26c4b704f62d38e7758ea25a32c15123 Mon Sep 17 00:00:00 2001 From: Ihor Rusyn <0kaba0@gmail.com> Date: Sun, 11 Oct 2026 23:09:20 +0200 Subject: [PATCH 1/2] feat(tsig): key lookup, root-only secret and tsigKeyId in the queue seconddns-tsig finds the account's TSIG key through the API (default or by label), checks a secret against the key's algorithm, writes a [tsig] section to seconddns.conf and the secret to /etc/seconddns/tsig.secret (0600) so the key can be put back if the panel rewrites its DNS config. With a [tsig] section, seconddns-queued creates zones with tsigKeyId; without one the request is unchanged. Installers fetch the tool, the uninstallers remove it and the secret file. --- .github/workflows/tests.yml | 3 + hosting-panels/common/seconddns-queued | 9 +- hosting-panels/common/seconddns-tsig | 113 +++++++++++++++++++ hosting-panels/common/seconddns_common.py | 36 ++++++ hosting-panels/common/tests/test_tsig.sh | 128 ++++++++++++++++++++++ hosting-panels/cpanel/install.sh | 1 + hosting-panels/cpanel/uninstall.sh | 2 + hosting-panels/cyberpanel/install.sh | 1 + hosting-panels/cyberpanel/uninstall.sh | 2 + hosting-panels/directadmin/install.sh | 1 + hosting-panels/directadmin/uninstall.sh | 2 + hosting-panels/plesk/install.sh | 1 + hosting-panels/plesk/uninstall.sh | 2 + 13 files changed, 299 insertions(+), 2 deletions(-) create mode 100755 hosting-panels/common/seconddns-tsig create mode 100755 hosting-panels/common/tests/test_tsig.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1ca0f89..7c1669a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,9 @@ jobs: - name: test_queue.sh run: bash hosting-panels/common/tests/test_queue.sh + - name: test_tsig.sh + run: bash hosting-panels/common/tests/test_tsig.sh + - name: test_valid_ip.sh run: bash hosting-panels/common/tests/test_valid_ip.sh diff --git a/hosting-panels/common/seconddns-queued b/hosting-panels/common/seconddns-queued index 8b9e9aa..1118232 100755 --- a/hosting-panels/common/seconddns-queued +++ b/hosting-panels/common/seconddns-queued @@ -92,6 +92,7 @@ def load_config(): "api_url": api_url.rstrip("/"), "api_key": api_key, "delete_check_master_ip": cp.getboolean("seconddns", "delete_check_master_ip", fallback=True), + "tsig_key_id": cp.get("tsig", "key_id", fallback="").strip(), "poll_interval": cp.getint("queue", "poll_interval", fallback=60), "http_timeout": cp.getint("queue", "http_timeout", fallback=15), "backoff_min": cp.getint("queue", "backoff_min", fallback=30), @@ -226,8 +227,10 @@ def detail(code, data): def send_op(cfg, op, domain, master_ip): """Deliver one operation. Returns (DONE / RETRY / FAILED, detail string).""" if op == "create": - code, data = api(cfg, "POST", "/api/zones", - {"name": domain, "masterIp": master_ip}) + payload = {"name": domain, "masterIp": master_ip} + if cfg["tsig_key_id"]: + payload["tsigKeyId"] = cfg["tsig_key_id"] + code, data = api(cfg, "POST", "/api/zones", payload) if code in (200, 201): return DONE, detail(code, data) if code == 409: @@ -243,6 +246,8 @@ def send_op(cfg, op, domain, master_ip): log(f"[~] worker: zone {domain} already exists (treated as success)") return DONE, detail(code, data) if code in (400, 422): + if code == 422 and cfg["tsig_key_id"]: + log(f"[!] worker: zone {domain} rejected with TSIG key {cfg['tsig_key_id']} — key deleted from the account? rerun the installer with the new key") return FAILED, detail(code, data) return RETRY, detail(code, data) diff --git a/hosting-panels/common/seconddns-tsig b/hosting-panels/common/seconddns-tsig new file mode 100755 index 0000000..1d32b35 --- /dev/null +++ b/hosting-panels/common/seconddns-tsig @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright © 2025-2026 SecondDNS +# Licensed under GNU General Public License v3.0 or SecondDNS Commercial License +# See LICENSE (GPLv3) or LICENSE.COMMERCIAL (commercial) for details +"""TSIG key of this panel server, for the installers. + + seconddns-tsig resolve [--key=LABEL] [--write [--secret=SECRET]] + find the key in the account (default key without --key) and print "id name algorithm"; + --write stores them in the [tsig] section, and the secret in a root-only file + seconddns-tsig check-secret ALGORITHM SECRET exit 0 when the secret fits the algorithm + seconddns-tsig secret print the stored secret (root) + seconddns-tsig clear remove the [tsig] section and the secret file + +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. +With a [tsig] section, seconddns-queued creates zones with that key (tsigKeyId). +""" +import argparse +import configparser +import os +import re +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 # noqa: E402 + + +def write_section(path, body): + """Replace or drop the [tsig] section, leaving the rest of the file as it is.""" + with open(path) as f: + text = f.read() + text = re.sub(r"(?ms)^\[tsig\]\n.*?(?=^\[|\Z)", "", text).rstrip("\n") + "\n" + if body: + text += "\n[tsig]\n" + body + tmp = path + ".tmp" + with open(tmp, "w") as f: + f.write(text) + os.chmod(tmp, os.stat(path).st_mode & 0o777) + os.replace(tmp, path) + + +SECRET_FILE = os.environ.get("SECONDDNS_TSIG_SECRET", "/etc/seconddns/tsig.secret") + + +def write_secret(secret): + os.makedirs(os.path.dirname(SECRET_FILE), mode=0o700, exist_ok=True) + fd = os.open(SECRET_FILE + ".tmp", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(secret + "\n") + os.replace(SECRET_FILE + ".tmp", SECRET_FILE) + + +def stored_secret_file(path): + cp = configparser.ConfigParser() + cp.read(path) + return cp.get("tsig", "secret_file", fallback="").strip() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.environ.get("SECONDDNS_CONF", "/etc/seconddns.conf")) + sub = ap.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("resolve") + r.add_argument("--key", metavar="LABEL") + r.add_argument("--write", action="store_true") + r.add_argument("--secret") + c = sub.add_parser("check-secret") + c.add_argument("algorithm") + c.add_argument("secret") + sub.add_parser("secret") + sub.add_parser("clear") + a = ap.parse_args() + + if a.cmd == "check-secret": + why = check_tsig_secret(a.algorithm, a.secret) + if why: + sys.exit(why) + return + + if a.cmd == "secret": + f = stored_secret_file(a.config) + if not f: + sys.exit("no TSIG secret stored") + with open(f) as fh: + print(fh.read().strip()) + return + + if a.cmd == "clear": + f = stored_secret_file(a.config) + write_section(a.config, "") + if f and os.path.exists(f): + os.remove(f) + return + + key, err = resolve_tsig_key(load_config(a.config), a.key) + if err: + sys.exit(err) + if a.secret is not None: + why = check_tsig_secret(key["algorithm"], a.secret) + if why: + sys.exit(f"{key['name']}: {why}") + if a.write: + body = f"key_id = {key['id']}\nkey_name = {key['name']}\nalgorithm = {key['algorithm']}\n" + if a.secret is not None: + write_secret(a.secret) + body += f"secret_file = {SECRET_FILE}\n" + write_section(a.config, body) + print(key["id"], key["name"], key["algorithm"]) + + +if __name__ == "__main__": + main() diff --git a/hosting-panels/common/seconddns_common.py b/hosting-panels/common/seconddns_common.py index 868c3c9..f2f4043 100644 --- a/hosting-panels/common/seconddns_common.py +++ b/hosting-panels/common/seconddns_common.py @@ -2,6 +2,8 @@ # Licensed under GNU General Public License v3.0 or SecondDNS Commercial License # See LICENSE (GPLv3) or LICENSE.COMMERCIAL (commercial) for details """Shared bits for the SecondDNS command-line tools (config, API, panel domains).""" +import base64 +import binascii import configparser import json import os @@ -212,3 +214,37 @@ def enqueue(op, domain, master_ip=""): """Hand an operation to the local queue. Returns True when accepted.""" r = subprocess.run([QUEUE_BIN, "enqueue", op, domain, master_ip], capture_output=True, text=True) return r.returncode == 0 + + +# Secret length per algorithm; keys created in the dashboard use exactly these. +TSIG_SECRET_BYTES = {"hmac-sha256": 32, "hmac-sha512": 64} + + +def resolve_tsig_key(cfg, label=None): + """The account's TSIG key by label, or its default key. Returns (key, error).""" + code, keys = api(cfg, "GET", "/api/user/tsig-keys", ua="SecondDNS-Tsig/1.0") + if code != 200 or not isinstance(keys, list): + return None, f"GET /api/user/tsig-keys failed: HTTP {code}" + if label: + key = next((k for k in keys if k.get("label") == label), None) + if not key: + return None, f"no TSIG key labelled '{label}' in this account" + else: + key = next((k for k in keys if k.get("isDefault")), None) + if not key: + return None, "the account has no default TSIG key; pass --key=