dns_manager.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import os
  2. import re
  3. import time
  4. import datetime
  5. import subprocess
  6. import secrets
  7. import config
  8. from core.file_manager import read_file, write_file
  9. def backup_zone(domain, content):
  10. ts = int(time.time())
  11. write_file(os.path.join(config.BACKUP_DIR, f"{domain}_{ts}.db"), content)
  12. return ts
  13. def get_backups(domain):
  14. if not os.path.exists(config.BACKUP_DIR): return []
  15. files = [f for f in os.listdir(config.BACKUP_DIR) if f.startswith(f"{domain}_") and f.endswith(".db")]
  16. backups = []
  17. for f in sorted(files, reverse=True):
  18. ts = f.replace(f"{domain}_", "").replace(".db", "")
  19. dt = datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
  20. backups.append({"file": f, "time": dt, "ts": ts})
  21. return backups
  22. def check_zone_syntax(domain, content):
  23. # FIX 3: Μοναδικό temp file για να μην υπάρξει ποτέ σύγκρουση (Race Condition)
  24. tmp_path = os.path.join("/tmp", f"bind_check_{domain}_{secrets.token_hex(4)}.db")
  25. write_file(tmp_path, content)
  26. res = subprocess.run(["named-checkzone", domain, tmp_path], capture_output=True, text=True)
  27. try: os.remove(tmp_path)
  28. except: pass
  29. if res.returncode == 0: return True, ""
  30. else: return False, res.stdout.replace(tmp_path, 'zone_file').strip()
  31. def get_ds_records(domain):
  32. try:
  33. res = subprocess.run(["rndc", "dnssec", "-status", domain], capture_output=True, text=True)
  34. if res.returncode == 0: return [line for line in res.stdout.split('\n') if line]
  35. except: pass
  36. return []
  37. def apply_template(content, domain, t_data):
  38. replacements = {
  39. "{DOMAIN}": domain, "{SERIAL}": time.strftime('%Y%m%d01'),
  40. "{TTL}": str(t_data.get('ttl', '86400')), "{REFRESH}": str(t_data.get('refresh', '3600')),
  41. "{RETRY}": str(t_data.get('retry', '1800')), "{EXPIRE}": str(t_data.get('expire', '604800'))
  42. }
  43. for k, v in t_data.get('custom_vars', {}).items(): replacements[f"{{{k.upper()}}}"] = str(v)
  44. loops = 0
  45. while loops < 10:
  46. old_content = content
  47. for k, v in replacements.items(): content = content.replace(k, v)
  48. if old_content == content: break
  49. loops += 1
  50. return content
  51. def sanitize_record_name(name, domain):
  52. name = name.strip()
  53. if name.endswith('.'): return name
  54. if domain.endswith('.in-addr.arpa'):
  55. base_ip = ".".join(reversed(domain.replace('.in-addr.arpa', '').split('.')))
  56. if name.startswith(base_ip + "."):
  57. remainder = name[len(base_ip)+1:]
  58. rem_reversed = ".".join(reversed(remainder.split('.')))
  59. return f"{rem_reversed}.{domain}."
  60. elif re.match(r'^\d+(?:\.\d+)*$', name):
  61. rem_reversed = ".".join(reversed(name.split('.')))
  62. return f"{rem_reversed}.{domain}."
  63. if name != '@' and not name.endswith('.'): return f"{name}.{domain}."
  64. return name
  65. def get_zone_block(conf, domain):
  66. parts = conf.split('zone "')
  67. for p in parts[1:]:
  68. if p.startswith(domain + '"'):
  69. block = 'zone "' + p
  70. open_braces = 0
  71. in_block = False
  72. for i, char in enumerate(block):
  73. if char == '{':
  74. open_braces += 1
  75. in_block = True
  76. elif char == '}':
  77. open_braces -= 1
  78. if in_block and open_braces == 0:
  79. if i + 1 < len(block) and block[i+1] == ';': return block[:i+2]
  80. return block[:i+1]
  81. return block
  82. return None
  83. def get_zone_file_path(domain):
  84. conf = read_file(config.LOCAL_CONF)
  85. block = get_zone_block(conf, domain)
  86. if block:
  87. m = re.search(r'file\s+"([^"]+)";', block)
  88. if m:
  89. p = m.group(1)
  90. return p if p.startswith('/') else os.path.join(config.CACHE_PATH if "type slave" in block else config.BIND_PATH, p)
  91. if domain == ".": return os.path.join(config.BIND_PATH, "db.root")
  92. return os.path.join(config.BIND_PATH, f"db.{domain}")
  93. def parse_zone_file(path, domain=None):
  94. records = {"A":[], "AAAA":[], "CNAME":[], "MX":[], "TXT":[], "NS":[], "SRV":[], "PTR":[]}
  95. content = read_file(path)
  96. if domain and domain != "." and ('\0' in content or 'SOA' not in content.upper()):
  97. try:
  98. res = subprocess.run(["named-compilezone", "-q", "-f", "raw", "-F", "text", "-o", "-", domain, path], capture_output=True, text=True)
  99. if res.returncode == 0: content = res.stdout
  100. except: pass
  101. if not content: return records
  102. is_reverse = domain and domain.endswith('.in-addr.arpa')
  103. for line in content.split('\n'):
  104. clean = line.split(';')[0].strip()
  105. if not clean or (domain != "." and 'SOA' in clean) or clean.startswith('$'): continue
  106. tokens = clean.split()
  107. ridx = -1
  108. for i, t in enumerate(tokens):
  109. if t.upper() in records: ridx = i; break
  110. if ridx != -1:
  111. rtype = tokens[ridx].upper()
  112. name = re.sub(r'\.{2,}', '.', tokens[0])
  113. if name.upper() in ['IN', '@'] or name.isdigit(): name = "@"
  114. # FIX 2: Αλεξίσφαιρη ανάγνωση της τιμής χωρίς να σπάει αν το name περιέχει "TXT" ή "A"
  115. match = re.search(rf'\s+(?:IN\s+)?{rtype}\s+(.*)', clean, re.IGNORECASE)
  116. val = match.group(1).strip() if match else " ".join(tokens[ridx+1:])
  117. friendly_name = name
  118. if is_reverse and name != '@':
  119. clean_name = name.lower().replace('.in-addr.arpa.', '')
  120. parts = clean_name.split('.')
  121. if all(p.isdigit() for p in parts): friendly_name = ".".join(reversed(parts))
  122. records[rtype].append({"name": name, "friendly_name": friendly_name, "value": val, "raw": line})
  123. return records
  124. def increment_serial(content):
  125. # FIX 1 (CRITICAL): Έξυπνος SOA Serial Parser
  126. # Αντί να ψάχνει σχόλια, βρίσκει το "SOA", ανοίγει την παρένθεση "(" και αρπάζει τον 1ο αριθμό που θα βρει!
  127. match = re.search(r'(SOA\s+.*?\(\s*)(\d+)', content, re.IGNORECASE | re.DOTALL)
  128. if match:
  129. prefix = match.group(1)
  130. old_serial = match.group(2)
  131. today_prefix = time.strftime('%Y%m%d')
  132. # Αν το serial είναι ήδη σημερινό (π.χ. 2026042001), του προσθέτουμε +1
  133. if old_serial.startswith(today_prefix):
  134. new_serial = str(int(old_serial) + 1)
  135. else:
  136. # Αλλιώς ξεκινάμε με σημερινή ημερομηνία + 01
  137. new_serial = today_prefix + "01"
  138. return content[:match.start()] + prefix + new_serial + content[match.end():]
  139. return content
  140. def construct_record_value(req_form, rtype):
  141. v = req_form.get('value', '').strip()
  142. if not v: return ""
  143. if rtype == 'TXT':
  144. v = v.strip('"')
  145. return f'"{v}"'
  146. if rtype in ['PTR', 'CNAME', 'NS'] and not v.endswith('.'): v = f"{v}."
  147. elif rtype in ['MX', 'SRV']:
  148. parts = v.split()
  149. if len(parts) >= 2 and not parts[-1].endswith('.'):
  150. parts[-1] = f"{parts[-1]}."
  151. v = " ".join(parts)
  152. return v
  153. def get_zone_masters_list(domain):
  154. conf = read_file(config.LOCAL_CONF)
  155. block = get_zone_block(conf, domain)
  156. if not block: return []
  157. if "type slave" in block or "type forward" in block:
  158. import re
  159. m = re.search(r'(?:masters|forwarders)\s*\{\s*(.*?)\s*\};', block, re.S | re.I)
  160. if m:
  161. ips_raw = m.group(1).replace('\n', ' ')
  162. return [ip.strip() for ip in ips_raw.split(';') if ip.strip()]
  163. return []