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
19 changes: 19 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
223 changes: 168 additions & 55 deletions hosting-panels/common/seconddns-tsig

Large diffs are not rendered by default.

84 changes: 67 additions & 17 deletions hosting-panels/common/seconddns_bind.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -98,15 +87,30 @@ 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()), " ")
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
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):
Expand Down Expand Up @@ -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
41 changes: 41 additions & 0 deletions hosting-panels/common/tests/fixtures/plesk-named.conf
Original file line number Diff line number Diff line change
@@ -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. --
152 changes: 152 additions & 0 deletions hosting-panels/common/tests/test_tsig_plesk.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
[seconddns]
api_url = http://127.0.0.1:$PORT
api_key = test-key
master_ip = 192.0.2.10
EOF
echo '[{"id":"z1","name":"example-one.test","masterIp":"192.0.2.10","tsigKeyId":null}]' > "$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 ]
67 changes: 67 additions & 0 deletions hosting-panels/common/tests/test_tsig_plesk_chroot.sh
Original file line number Diff line number Diff line change
@@ -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 <chroot>. 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" <<EOF
[seconddns]
api_url = http://127.0.0.1:$PORT
api_key = test-key
master_ip = 192.0.2.10
EOF
python3 - "$PORT" > /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 <chroot>" || 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 ]
Loading
Loading