Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions hosting-panels/common/seconddns-queued
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
134 changes: 134 additions & 0 deletions hosting-panels/common/seconddns-tsig
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/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 --tsig=[ALGORITHM:]KEY:SECRET [--write]
find KEY in the account by its label or full name (as in Settings › DNS), check
the secret against it and print "id name algorithm"; --write stores them in the
[tsig] section, and the secret in a root-only file
seconddns-tsig status the key this server uses, and whether the
account still has it
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, parse_tsig_pair # 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("--tsig", metavar="[ALGORITHM:]KEY:SECRET", required=True)
r.add_argument("--write", action="store_true")
c = sub.add_parser("check-secret")
c.add_argument("algorithm")
c.add_argument("secret")
sub.add_parser("secret")
sub.add_parser("status")
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 == "status":
cp = configparser.ConfigParser()
cp.read(a.config)
if not cp.has_section("tsig"):
print("TSIG: off (zones are transferred without a key)")
return
t = cp["tsig"]
print(f"TSIG key: {t.get('key_name')} ({t.get('algorithm')}), id {t.get('key_id')}")
print(f"secret: {'stored' if t.get('secret_file') and os.path.exists(t.get('secret_file')) else 'not stored'}")
key, err = resolve_tsig_key(load_config(a.config), t.get("key_name"))
if err:
sys.exit(f"account: {err}")
if key["id"] != t.get("key_id"):
sys.exit(f"account: a key named {key['name']} exists with another id ({key['id']}); it was recreated — run enable again")
print(f"account: present, label '{key['label']}'")
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

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}")
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")
print(key["id"], key["name"], key["algorithm"])


if __name__ == "__main__":
main()
46 changes: 46 additions & 0 deletions hosting-panels/common/seconddns_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -212,3 +214,47 @@ 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, ref):
"""The account's TSIG key by label or full name, as shown in Settings › DNS. 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}"
key = next((k for k in keys if ref and ref in (k.get("label"), k.get("name"))), None)
if key:
return key, None
have = ", ".join(f"{k.get('name')} ({k.get('label')})" for k in keys) or "none — create one in Settings › DNS"
return None, f"no TSIG key '{ref}' in this account; keys: {have}"


def parse_tsig_pair(value):
"""'[algorithm:]key:secret', as dig -y takes it. Returns (algorithm or None, key, secret, error)."""
parts = (value or "").split(":")
if len(parts) == 2:
algorithm, (ref, secret) = None, parts
elif len(parts) == 3:
algorithm, ref, secret = parts
else:
return None, None, None, "expected --tsig=KEY:SECRET (or ALGORITHM:KEY:SECRET)"
if not ref or not secret:
return None, None, None, "both the key and the secret are needed: --tsig=KEY:SECRET"
return algorithm, ref, secret, None


def check_tsig_secret(algorithm, secret):
"""None when the secret fits the algorithm, else the reason."""
want = TSIG_SECRET_BYTES.get(algorithm)
if not want:
return f"unsupported algorithm {algorithm}"
try:
raw = base64.b64decode(secret, validate=True)
except (binascii.Error, ValueError):
return "the secret is not valid base64"
if len(raw) != want:
return f"the secret is {len(raw)} bytes, {algorithm} keys are {want}"
return None
146 changes: 146 additions & 0 deletions hosting-panels/common/tests/test_tsig.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/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 (key lookup, secret check, [tsig] section, secret file) and
# the queue sending tsigKeyId. Requires: bash, python3, sqlite3. No network.
#
# Usage: ./test_tsig.sh

set -u

HERE="$(cd "$(dirname "$0")" && pwd)"
TSIG="$HERE/../seconddns-tsig"
QUEUE="$HERE/../seconddns-queue"
export SECONDDNS_QUEUED_BIN="$HERE/../seconddns-queued"
TMP="$(mktemp -d)"
PORT=8897

export SECONDDNS_CONF="$TMP/seconddns.conf"
export SECONDDNS_TSIG_SECRET="$TMP/etc-seconddns/tsig.secret"
export SECONDDNS_QUEUE_DB="$TMP/queue.db"
export SECONDDNS_QUEUE_LOCK="$TMP/queue.lock"
export SECONDDNS_LOG="$TMP/seconddns.log"
export SECONDDNS_QUEUE_SOCK="$TMP/w.sock"

write_conf() {
cat > "$SECONDDNS_CONF" <<EOF
[seconddns]
api_url = http://127.0.0.1:$PORT
api_key = test-key
master_ip = 192.0.2.10

[queue]
poll_interval = 1
http_timeout = 5
backoff_min = 1
backoff_max = 2
EOF
chmod 644 "$SECONDDNS_CONF"
}

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')"; }

# Mock API. $TMP/keys holds the GET /api/user/tsig-keys body; POST /api/zones
# bodies are appended to $TMP/posts.log; $TMP/mode=reject answers creates with 422.
python3 - "$PORT" "$TMP" > /dev/null 2>&1 <<'PYEOF' &
import http.server, sys
port, tmp = int(sys.argv[1]), sys.argv[2]
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a): pass
def reply(self, code, body=b'{}'):
self.send_response(code); self.send_header('Content-Type','application/json'); self.end_headers(); self.wfile.write(body)
def do_GET(self):
if self.path.split('?')[0] == '/api/user/tsig-keys':
self.reply(200, open(tmp+'/keys','rb').read()); return
self.reply(404)
def do_POST(self):
body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
with open(tmp+'/posts.log','ab') as f: f.write(body + b'\n')
try: mode = open(tmp+'/mode').read().strip()
except Exception: mode = 'ok'
self.reply(422, b'{"error":"unknown tsigKeyId"}') if mode == 'reject' else self.reply(201, b'{"id":"z1"}')
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

KEYS_TWO='[{"id":"k1","label":"main","name":"acct-main","algorithm":"hmac-sha256","isDefault":false},
{"id":"k2","label":"web","name":"acct-web","algorithm":"hmac-sha512","isDefault":true}]'
SECRET256=$(python3 -c 'import base64,os;print(base64.b64encode(os.urandom(32)).decode())')
SECRET512=$(python3 -c 'import base64,os;print(base64.b64encode(os.urandom(64)).decode())')
write_conf

echo "== 1. resolve: one KEY:SECRET pair, key by label or full name"
echo "$KEYS_TWO" > "$TMP/keys"
"$TSIG" resolve > /dev/null 2>&1 && fail "resolve without --tsig accepted" || ok "resolve without --tsig refused"
"$TSIG" resolve --tsig=main > /dev/null 2>&1 && fail "key without a secret accepted" || ok "key without a secret refused"
assert_eq "$("$TSIG" resolve --tsig="main:$SECRET256")" "k1 acct-main hmac-sha256" "label picks that key"
assert_eq "$("$TSIG" resolve --tsig="acct-web:$SECRET512")" "k2 acct-web hmac-sha512" "full name picks that key"
assert_eq "$("$TSIG" resolve --tsig="hmac-sha256:acct-main:$SECRET256")" "k1 acct-main hmac-sha256" "dig -y form with the algorithm"
"$TSIG" resolve --tsig="hmac-sha512:main:$SECRET256" > /dev/null 2>&1 && fail "wrong algorithm prefix accepted" || ok "algorithm prefix must match the key"
"$TSIG" resolve --tsig="web:$SECRET256" > /dev/null 2>&1 && fail "secret of the wrong length accepted" || ok "secret checked against the key's algorithm"
err=$("$TSIG" resolve --tsig="nope:$SECRET256" 2>&1 >/dev/null); rc=$?
[ "$rc" -ne 0 ] && ok "unknown key exits non-zero" || fail "unknown key accepted"
echo "$err" | grep -q "acct-main (main), acct-web (web)" && ok "the error lists the account's keys" || fail "the error does not list the keys: $err"

echo "== 2. check-secret"
"$TSIG" check-secret hmac-sha256 "$SECRET256" && ok "32-byte secret fits hmac-sha256" || fail "32-byte secret rejected"
"$TSIG" check-secret hmac-sha512 "$SECRET256" 2>/dev/null && fail "32-byte secret accepted for hmac-sha512" || ok "32-byte secret rejected for hmac-sha512"
"$TSIG" check-secret hmac-sha512 "$SECRET512" && ok "64-byte secret fits hmac-sha512" || fail "64-byte secret rejected"
"$TSIG" check-secret hmac-sha256 'not*base64' 2>/dev/null && fail "non-base64 accepted" || ok "non-base64 rejected"
"$TSIG" check-secret hmac-md5 "$SECRET256" 2>/dev/null && fail "hmac-md5 accepted" || ok "unsupported algorithm rejected"

echo "== 3. resolve --write: section and secret file"
echo "$KEYS_TWO" > "$TMP/keys"
"$TSIG" resolve --tsig="main:$SECRET512" --write > /dev/null 2>&1 && fail "secret of the wrong length written" || ok "wrong-length secret refused before writing"
[ -e "$SECONDDNS_TSIG_SECRET" ] && fail "secret file created by a refused write" || ok "nothing written on refusal"
"$TSIG" resolve --tsig="main:$SECRET256" --write > /dev/null
section=$(python3 -c "import configparser,sys;c=configparser.ConfigParser();c.read(sys.argv[1]);print(c['tsig']['key_id'],c['tsig']['key_name'],c['tsig']['algorithm'],c['tsig']['secret_file'])" "$SECONDDNS_CONF")
assert_eq "$section" "k1 acct-main hmac-sha256 $SECONDDNS_TSIG_SECRET" "[tsig] section written"
grep -q "$SECRET256" "$SECONDDNS_CONF" && fail "secret leaked into the world-readable config" || ok "secret not in the config"
assert_eq "$(stat -c %a "$SECONDDNS_TSIG_SECRET" 2>/dev/null || stat -f %Lp "$SECONDDNS_TSIG_SECRET")" 600 "secret file is 0600"
assert_eq "$("$TSIG" secret)" "$SECRET256" "secret reads back"
grep -q '^api_key = test-key$' "$SECONDDNS_CONF" && ok "rest of the config kept" || fail "rest of the config lost"
assert_eq "$(stat -c %a "$SECONDDNS_CONF" 2>/dev/null || stat -f %Lp "$SECONDDNS_CONF")" 644 "config mode kept"
"$TSIG" resolve --tsig="web:$SECRET512" --write > /dev/null
assert_eq "$(grep -c '^\[tsig\]' "$SECONDDNS_CONF")" 1 "rewrite keeps one [tsig] section"
assert_eq "$(python3 -c "import configparser,sys;c=configparser.ConfigParser();c.read(sys.argv[1]);print(c['tsig']['key_id'])" "$SECONDDNS_CONF")" k2 "rewrite replaces the key"

echo "== 4. status"
"$TSIG" resolve --tsig="main:$SECRET256" --write > /dev/null
out=$("$TSIG" status); rc=$?
assert_eq "$rc" 0 "status exits 0 while the key is in the account"
echo "$out" | grep -q "TSIG key: acct-main (hmac-sha256), id k1" && ok "status names the key" || fail "status output: $out"
echo "$out" | grep -q "label 'main'" && ok "status shows the label" || fail "status label: $out"
echo '[{"id":"k9","label":"main","name":"acct-main","algorithm":"hmac-sha256","isDefault":false}]' > "$TMP/keys"
"$TSIG" status > /dev/null 2>&1 && fail "recreated key reported as fine" || ok "recreated key (new id) exits non-zero"
echo '[]' > "$TMP/keys"
"$TSIG" status > /dev/null 2>&1 && fail "deleted key reported as fine" || ok "deleted key exits non-zero"
echo "$KEYS_TWO" > "$TMP/keys"

echo "== 5. queue sends tsigKeyId only with a [tsig] section"
"$TSIG" resolve --tsig="main:$SECRET256" --write > /dev/null
: > "$TMP/posts.log"
"$QUEUE" enqueue create signed.example.com 192.0.2.10 && "$QUEUE" flush
assert_eq "$(python3 -c "import json,sys;print(json.loads(open(sys.argv[1]).readline()).get('tsigKeyId'))" "$TMP/posts.log")" k1 "create carries tsigKeyId"
echo reject > "$TMP/mode"
"$QUEUE" enqueue create rejected.example.com 192.0.2.10; "$QUEUE" flush
grep -q "rejected with TSIG key k1" "$SECONDDNS_LOG" && ok "422 with a key is logged with the key id" || fail "422 with a key not explained in the log"
rm -f "$TMP/mode"
"$TSIG" clear
grep -q '^\[tsig\]' "$SECONDDNS_CONF" && fail "clear left the section" || ok "clear removes the section"
assert_eq "$("$TSIG" status)" "TSIG: off (zones are transferred without a key)" "status after clear"
[ -e "$SECONDDNS_TSIG_SECRET" ] && fail "clear left the secret file" || ok "clear removes the secret file"
: > "$TMP/posts.log"
"$QUEUE" enqueue create plain.example.com 192.0.2.10 && "$QUEUE" flush
assert_eq "$(head -1 "$TMP/posts.log")" '{"name": "plain.example.com", "masterIp": "192.0.2.10"}' "without [tsig] the create body is unchanged"

echo
echo "passed: $PASS, failed: $FAIL"
[ "$FAIL" -eq 0 ]
1 change: 1 addition & 0 deletions hosting-panels/cpanel/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ curl -sf --max-time 10 -o /usr/local/bin/seconddns-queue "$COMMON_URL/seconddns-
curl -sf --max-time 10 -o /usr/local/bin/seconddns-queued "$COMMON_URL/seconddns-queued?t=$(date +%s)"
curl -sf --max-time 10 -o /etc/systemd/system/seconddns-queued.service "$COMMON_URL/seconddns-queued.service?t=$(date +%s)"
chmod +x /usr/local/bin/seconddns /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
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)")
Expand Down
2 changes: 2 additions & 0 deletions hosting-panels/cpanel/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ 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
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)
[ "${n:-0}" -gt 0 ] && echo "[!] Discarding $n pending zone operation(s) that were never delivered to SecondDNS"
Expand Down
Loading
Loading