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
13 changes: 13 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
354 changes: 340 additions & 14 deletions hosting-panels/common/seconddns-tsig

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions hosting-panels/common/seconddns_bind.py
Original file line number Diff line number Diff line change
@@ -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()]
7 changes: 6 additions & 1 deletion hosting-panels/common/tests/test_named_conf_edit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading