bind9_add_record.py: DNS-Record in BIND9-Master-Zone anlegen
Ermittelt Zonendatei aus named.conf, fügt Record hinzu, erhöht SOA-Serial, validiert mit named-checkzone und lädt per rndc reload neu. Läuft als root. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Python-Cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Zonendatei-Sicherungen, die das Skript ggf. anlegt
|
||||||
|
*.bak
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Legt einen DNS-Record in einer lokalen BIND9-Master-Zone an.
|
||||||
|
|
||||||
|
Ablauf: Zonendatei aus named.conf.local ermitteln -> Record (falls noch nicht
|
||||||
|
vorhanden) anhängen -> SOA-Serial erhöhen -> mit named-checkzone validieren ->
|
||||||
|
mit rndc reload neu laden. Bei DNSSEC-Zonen (inline-signing) signiert BIND nach
|
||||||
|
dem Reload automatisch neu.
|
||||||
|
|
||||||
|
Muss als root laufen (Zonendatei schreiben + rndc): sudo python3 bind9_add_record.py ...
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --- KONFIGURATION ---
|
||||||
|
NAMED_CONF_LOCAL = "/etc/bind/named.conf.local"
|
||||||
|
NAMED_CONF_OPTIONS = "/etc/bind/named.conf.options"
|
||||||
|
RNDC = "/usr/sbin/rndc"
|
||||||
|
NAMED_CHECKZONE = "/usr/bin/named-checkzone"
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory():
|
||||||
|
"""Liest options { directory "..."; } aus named.conf.options."""
|
||||||
|
text = read_file(NAMED_CONF_OPTIONS)
|
||||||
|
m = re.search(r'directory\s+"([^"]+)"', text)
|
||||||
|
return m.group(1) if m else "."
|
||||||
|
|
||||||
|
|
||||||
|
def get_zone_file(zone):
|
||||||
|
"""Ermittelt den Pfad der Zonendatei für eine Zone aus named.conf.local."""
|
||||||
|
text = read_file(NAMED_CONF_LOCAL)
|
||||||
|
# Beginn des zone-Blocks finden: zone "<zone>" [in] {
|
||||||
|
head = re.search(r'zone\s+"' + re.escape(zone) + r'"\s*(?:in\s+)?\{', text, re.I)
|
||||||
|
if not head:
|
||||||
|
sys.exit(f"❌ Zone '{zone}' nicht in {NAMED_CONF_LOCAL} gefunden.")
|
||||||
|
# Passende schließende Klammer per Klammerzählung suchen
|
||||||
|
# (der Block enthält verschachtelte { } wie bei allow-transfer { ... }).
|
||||||
|
depth, i = 0, head.end() - 1
|
||||||
|
while i < len(text):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
block = text[head.end():i]
|
||||||
|
fm = re.search(r'file\s+"([^"]+)"', block)
|
||||||
|
if not fm:
|
||||||
|
sys.exit(f"❌ Keine 'file'-Angabe für Zone '{zone}' gefunden.")
|
||||||
|
path = fm.group(1)
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
path = os.path.join(get_directory(), path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def bump_serial(text):
|
||||||
|
"""Erhöht die SOA-Serial (Format YYYYMMDDnn). Liefert (text, alt, neu)."""
|
||||||
|
today = int(datetime.date.today().strftime("%Y%m%d")) * 100
|
||||||
|
|
||||||
|
# 1) explizit kommentierte Serial: 2026053101 ; Serial
|
||||||
|
m = re.search(r'(\d+)(\s*;\s*[Ss]erial)', text)
|
||||||
|
# 2) mehrzeilige SOA mit Klammer: SOA ... ( \n 2026053101
|
||||||
|
if not m:
|
||||||
|
m = re.search(r'(\bSOA\b[^(]*\(\s*)(\d+)', text, re.I)
|
||||||
|
if m:
|
||||||
|
old = int(m.group(2))
|
||||||
|
new = max(old + 1, today + 1)
|
||||||
|
text = text[:m.start(2)] + str(new) + text[m.end(2):]
|
||||||
|
return text, old, new
|
||||||
|
# 3) einzeilige SOA: SOA mname rname serial refresh ...
|
||||||
|
m = re.search(r'(\bSOA\b\s+\S+\s+\S+\s+)(\d+)', text, re.I)
|
||||||
|
if not m:
|
||||||
|
sys.exit("❌ Konnte die SOA-Serial nicht finden.")
|
||||||
|
old = int(m.group(2))
|
||||||
|
new = max(old + 1, today + 1)
|
||||||
|
text = text[:m.start(2)] + str(new) + text[m.end(2):]
|
||||||
|
return text, old, new
|
||||||
|
|
||||||
|
old = int(m.group(1))
|
||||||
|
new = max(old + 1, today + 1)
|
||||||
|
text = text[:m.start(1)] + str(new) + text[m.end(1):]
|
||||||
|
return text, old, new
|
||||||
|
|
||||||
|
|
||||||
|
def record_exists(text, name, rtype, content):
|
||||||
|
"""Grobe Prüfung, ob ein gleichwertiger Record bereits in der Zone steht."""
|
||||||
|
rtype = rtype.upper()
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith(";"):
|
||||||
|
continue
|
||||||
|
toks = s.split()
|
||||||
|
if not toks:
|
||||||
|
continue
|
||||||
|
if toks[0] == name and rtype in (t.upper() for t in toks) and content in s:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="DNS-Record in einer BIND9-Master-Zone anlegen.")
|
||||||
|
parser.add_argument("zone", help="Zonenname (z.B. maierch.de)")
|
||||||
|
parser.add_argument("name", help="Record-Name relativ zur Zone (z.B. srv9) oder @ / FQDN mit Punkt")
|
||||||
|
parser.add_argument("type", help="Record-Typ (A, AAAA, CNAME, TXT, MX, ...)")
|
||||||
|
parser.add_argument("content", help="Record-Wert (z.B. die IP-Adresse)")
|
||||||
|
parser.add_argument("--ttl", type=int, default=None, help="TTL in Sekunden (Standard: Zonen-$TTL)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
sys.exit("❌ Bitte mit root-Rechten ausführen: sudo python3 bind9_add_record.py ...")
|
||||||
|
|
||||||
|
rtype = args.type.upper()
|
||||||
|
zone_file = get_zone_file(args.zone)
|
||||||
|
print(f"📄 Zonendatei: {zone_file}")
|
||||||
|
|
||||||
|
text = read_file(zone_file)
|
||||||
|
|
||||||
|
# Duplikatsprüfung
|
||||||
|
if record_exists(text, args.name, rtype, args.content):
|
||||||
|
print(f"ℹ️ Record existiert bereits: {args.name} {rtype} {args.content} – keine Aktion.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Record-Zeile bauen
|
||||||
|
if args.ttl is not None:
|
||||||
|
record = f"{args.name}\t{args.ttl}\tIN\t{rtype}\t{args.content}"
|
||||||
|
else:
|
||||||
|
record = f"{args.name}\tIN\t{rtype}\t{args.content}"
|
||||||
|
|
||||||
|
if not text.endswith("\n"):
|
||||||
|
text += "\n"
|
||||||
|
text += record + "\n"
|
||||||
|
|
||||||
|
# SOA-Serial erhöhen
|
||||||
|
text, old_serial, new_serial = bump_serial(text)
|
||||||
|
print(f"🔢 Serial: {old_serial} -> {new_serial}")
|
||||||
|
print(f"➕ Neuer Record: {record.replace(chr(9), ' ')}")
|
||||||
|
|
||||||
|
# Sicherung anlegen
|
||||||
|
backup = zone_file + ".bak"
|
||||||
|
shutil.copy2(zone_file, backup)
|
||||||
|
|
||||||
|
# Schreiben
|
||||||
|
with open(zone_file, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
# Validieren
|
||||||
|
check = subprocess.run(
|
||||||
|
[NAMED_CHECKZONE, args.zone, zone_file],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if check.returncode != 0:
|
||||||
|
print("❌ named-checkzone meldet Fehler – stelle die Sicherung wieder her:")
|
||||||
|
print(check.stdout + check.stderr)
|
||||||
|
shutil.move(backup, zone_file)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"✅ named-checkzone OK")
|
||||||
|
|
||||||
|
# Neu laden
|
||||||
|
reload_res = subprocess.run([RNDC, "reload", args.zone], capture_output=True, text=True)
|
||||||
|
if reload_res.returncode != 0:
|
||||||
|
print("❌ rndc reload fehlgeschlagen:")
|
||||||
|
print(reload_res.stdout + reload_res.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.remove(backup)
|
||||||
|
print(f"✅ Zone '{args.zone}' neu geladen. Record ist aktiv.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user