app.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300
  1. from flask import Flask, render_template, request, redirect, flash, url_for, jsonify, send_file, session
  2. from werkzeug.security import check_password_hash, generate_password_hash
  3. from functools import wraps
  4. import subprocess, os, re, time, json, zipfile, io, datetime, urllib.request, secrets
  5. import config
  6. from core import file_manager as fm
  7. from core import dns_manager as dns
  8. from core import adblock_manager as ab
  9. from core import cluster_manager as cm
  10. from core import stats_manager as stats
  11. app = Flask(__name__)
  12. app.secret_key = "bind_pro_v108_modular_masterpiece"
  13. # Εκκίνηση του Background Daemon για τα Analytics
  14. stats.start_stats_daemon()
  15. # --- DECORATORS & AUTH ---
  16. def login_required(f):
  17. @wraps(f)
  18. def dec(*args, **kwargs):
  19. if 'username' not in session: return redirect(url_for('login'))
  20. return f(*args, **kwargs)
  21. return dec
  22. def admin_required(f):
  23. @wraps(f)
  24. def dec(*args, **kwargs):
  25. if session.get('role') != 'admin': return redirect(url_for('dashboard'))
  26. return f(*args, **kwargs)
  27. return dec
  28. def api_required(f):
  29. @wraps(f)
  30. def dec(*args, **kwargs):
  31. key = request.headers.get('X-API-Key')
  32. if not key or key != fm.load_panel_settings().get('api_key'):
  33. return jsonify({"status": "error", "message": "Unauthorized"}), 401
  34. return f(*args, **kwargs)
  35. return dec
  36. # --- USER API AUTH DECORATOR ---
  37. def user_api_required(f):
  38. @wraps(f)
  39. def dec(*args, **kwargs):
  40. token = request.headers.get('Authorization') # Expected: Bearer <token>
  41. if token and token.startswith('Bearer '):
  42. token = token.split(' ')[1]
  43. users = fm.load_users()
  44. for username, data in users.items():
  45. if data.get('api_token') == token:
  46. session['username'] = username
  47. session['role'] = data.get('role')
  48. return f(*args, **kwargs)
  49. return jsonify({"status": "error", "message": "Invalid or missing API Token"}), 401
  50. return dec
  51. def check_zone_access(domain):
  52. if session.get('role') == 'admin': return True
  53. return fm.load_panel_settings().get('zone_owners', {}).get(domain) == session.get('username')
  54. # --- AUTH ROUTES ---
  55. @app.route('/login', methods=['GET', 'POST'])
  56. def login():
  57. if request.method == 'POST':
  58. u, p = request.form.get('username'), request.form.get('password')
  59. users = fm.load_users()
  60. if u in users and check_password_hash(users[u]['password'], p):
  61. session.update({'username': u, 'role': users[u]['role']})
  62. return redirect(url_for('dashboard'))
  63. flash("Invalid Credentials", "error")
  64. return render_template('login.html')
  65. @app.route('/logout')
  66. def logout():
  67. session.clear()
  68. return redirect(url_for('login'))
  69. @app.route('/profile')
  70. @login_required
  71. def profile():
  72. users = fm.load_users()
  73. user_data = users.get(session['username'], {})
  74. return render_template('profile.html', user_data=user_data)
  75. # --- MAIN DASHBOARD & ZONES ---
  76. @app.route('/')
  77. @login_required
  78. def dashboard():
  79. conf = fm.read_file(config.LOCAL_CONF)
  80. zones_raw = re.findall(r'zone\s+"([^"]+)"\s*\{.*?type\s+(master|slave|forward|hint);', conf, re.DOTALL)
  81. zones_raw = [z for z in zones_raw if z[0] != "rpz.blacklist"]
  82. settings = fm.load_panel_settings()
  83. all_templates = settings.get('templates', {})
  84. owners = settings.get('zone_owners', {})
  85. users = fm.load_users()
  86. current_user_data = users.get(session['username'], {})
  87. # Φιλτράρισμα Ζωνών & Templates
  88. if session['role'] == 'admin':
  89. zones = zones_raw
  90. available_templates = all_templates
  91. else:
  92. zones = [z for z in zones_raw if owners.get(z[0]) == session['username']]
  93. assigned_keys = current_user_data.get('assigned_templates', [])
  94. available_templates = {k: v for k, v in all_templates.items() if k in assigned_keys}
  95. try: status = subprocess.check_output(["systemctl", "is-active", config.BIND_SERVICE]).decode().strip()
  96. except: status = "inactive"
  97. view_mode = users.get(session['username'], {}).get('view_mode', 'cards')
  98. return render_template('dashboard.html', zones=zones, status=status, templates=available_templates,
  99. owners=owners, all_usernames=list(users.keys()), view_mode=view_mode)
  100. @app.route('/zones/add', methods=['POST'])
  101. @login_required
  102. def add_zone():
  103. raw_domain = request.form.get('domain', '').strip()
  104. domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', raw_domain).replace('../', '')
  105. z_type, direction = request.form.get('type'), request.form.get('direction', 'forward')
  106. if z_type != "hint" and direction == 'reverse' and not domain.endswith('.arpa'):
  107. domain = re.sub(r'/.*$', '', domain)
  108. parts = domain.strip('.').split('.')
  109. if len(parts) == 4 and parts[3] == '0': parts = parts[:3]
  110. domain = ".".join(reversed(parts)) + ".in-addr.arpa"
  111. if z_type == "hint": domain = "."
  112. conf = fm.read_file(config.LOCAL_CONF)
  113. default_zones = fm.read_file(os.path.join(config.BIND_PATH, "named.conf.default-zones"))
  114. if f'zone "{domain}"' in conf or f'zone "{domain}"' in default_zones:
  115. flash(f"Zone '{domain}' exists!", "error")
  116. return redirect(url_for('dashboard'))
  117. if z_type == "hint":
  118. hint_path = os.path.join(config.BIND_PATH, "db.root")
  119. try:
  120. urllib.request.urlretrieve("https://www.internic.net/domain/named.root", hint_path)
  121. subprocess.run(["chown", "bind:bind", hint_path])
  122. except:
  123. fm.write_file(hint_path, ". 3600000 IN NS A.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET. 3600000 A 198.41.0.4")
  124. conf += f'\nzone "." {{\n type hint;\n file "{hint_path}";\n}};\n'
  125. elif z_type == "master":
  126. path = os.path.join(config.BIND_PATH, f"db.{domain}")
  127. t_name = request.form.get('template_name')
  128. # MANDATORY TEMPLATE CHECK
  129. if not t_name:
  130. flash("Template is mandatory for Master zones!", "error")
  131. return redirect(url_for('dashboard'))
  132. if session['role'] != 'admin':
  133. assigned = fm.load_users().get(session['username'], {}).get('assigned_templates', [])
  134. if t_name not in assigned:
  135. flash("Access denied to this template!", "error")
  136. return redirect(url_for('dashboard'))
  137. t = fm.load_panel_settings()['templates'].get(t_name)
  138. if not t:
  139. flash("Template not found!", "error")
  140. return redirect(url_for('dashboard'))
  141. conf += f'\nzone "{domain}" {{\n type master;\n file "{path}";\n}};\n'
  142. fm.write_file(path, dns.apply_template(t['content'], domain, t))
  143. elif z_type == "forward":
  144. ips = "".join([f" {x.strip()};" for x in request.form.getlist('forwarders[]') if x.strip()])
  145. conf += f'\nzone "{domain}" {{\n type forward;\n forwarders {{{ips} }};\n}};\n'
  146. else:
  147. path = os.path.join(config.CACHE_PATH, f"db.{domain}")
  148. ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()])
  149. conf += f'\nzone "{domain}" {{\n type slave;\n file "{path}";\n masters {{{ips} }};\n masterfile-format text;\n}};\n'
  150. fm.write_file(config.LOCAL_CONF, conf)
  151. s = fm.load_panel_settings()
  152. s['zone_owners'][domain] = session['username']
  153. fm.save_panel_settings(s)
  154. subprocess.run(["rndc", "reconfig"])
  155. if z_type == "master": cm.broadcast_to_cluster('add_slave', domain)
  156. flash(f"Zone '{domain}' created!", "success")
  157. return redirect(url_for('dashboard'))
  158. @app.route('/zones/manage/<path:domain>')
  159. @login_required
  160. def manage_zone(domain):
  161. clean = "." if domain == "root_zone" else domain.strip('/')
  162. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  163. conf = fm.read_file(config.LOCAL_CONF)
  164. block = dns.get_zone_block(conf, clean)
  165. if not block: return redirect(url_for('dashboard'))
  166. path = dns.get_zone_file_path(clean)
  167. try:
  168. cmd = f"tail -n 5000 {config.QUERY_LOG_PATH} | grep -iE '({clean}|transfer|notify|axfr)' | tail -n 30"
  169. logs = subprocess.check_output(cmd, shell=True, text=True).strip().split('\n')
  170. except: logs = []
  171. records = dns.parse_zone_file(path, clean)
  172. has_records = any(len(r)>0 for r in records.values())
  173. m_ips = (re.search(r'masters\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ')
  174. f_ips = (re.search(r'forwarders\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ')
  175. z_type = "hint" if "type hint" in block else "slave" if "type slave" in block else "forward" if "type forward" in block else "master"
  176. audit_logs = fm.get_audit_log(clean)
  177. backups = dns.get_backups(clean)
  178. dnssec_enabled = "dnssec-policy default" in block
  179. ds_records = dns.get_ds_records(clean) if dnssec_enabled else []
  180. return render_template('manage_zone.html', domain=clean, type=z_type, records=records, has_records=has_records,
  181. path=path, logs=[l for l in logs if l], master_ips=m_ips, forwarders=f_ips,
  182. audit_logs=audit_logs, backups=backups, dnssec_enabled=dnssec_enabled, ds_records=ds_records)
  183. @app.route('/zones/delete/<path:domain>')
  184. @login_required
  185. def delete_zone(domain):
  186. clean = "." if domain == "root_zone" else domain.strip('/')
  187. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  188. conf = fm.read_file(config.LOCAL_CONF)
  189. path = dns.get_zone_file_path(clean)
  190. block = dns.get_zone_block(conf, clean)
  191. if block:
  192. fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
  193. s = fm.load_panel_settings()
  194. s['zone_owners'].pop(clean, None)
  195. fm.save_panel_settings(s)
  196. subprocess.run(["rndc", "reconfig"])
  197. try: os.remove(path)
  198. except: pass
  199. if "type master" in block: cm.broadcast_to_cluster('delete_zone', clean)
  200. flash("Zone Deleted", "success")
  201. return redirect(url_for('dashboard'))
  202. # --- ZONE ACTIONS (RECORDS, IP, DNSSEC) ---
  203. @app.route('/zones/manage/<path:domain>/add_record', methods=['POST'])
  204. @login_required
  205. def add_record(domain):
  206. clean = "." if domain == "root_zone" else domain
  207. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  208. path, rtype = dns.get_zone_file_path(clean), request.form.get('rtype')
  209. name = dns.sanitize_record_name(request.form.get('name'), clean)
  210. val = dns.construct_record_value(request.form, rtype)
  211. new_content = dns.increment_serial(fm.read_file(path)) + f"{name}\tIN\t{rtype}\t{val}\n"
  212. is_valid, err = dns.check_zone_syntax(clean, new_content)
  213. if not is_valid:
  214. flash(f"Linter Error: {err}", "error")
  215. return redirect(url_for('manage_zone', domain=domain))
  216. dns.backup_zone(clean, fm.read_file(path))
  217. fm.write_file(path, new_content)
  218. subprocess.run(["rndc", "reload", clean])
  219. fm.log_audit(session['username'], clean, f"Added record {rtype} {name}")
  220. flash("Record added", "success")
  221. return redirect(url_for('manage_zone', domain=domain))
  222. @app.route('/zones/manage/<path:domain>/delete_record', methods=['POST'])
  223. @login_required
  224. def delete_record(domain):
  225. clean = "." if domain == "root_zone" else domain
  226. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  227. path, raw = dns.get_zone_file_path(clean), request.form.get('raw_line').strip()
  228. lines = fm.read_file(path).split('\n')
  229. for i, l in enumerate(lines):
  230. if l.strip() == raw:
  231. del lines[i]
  232. break
  233. new_content = dns.increment_serial('\n'.join(lines))
  234. is_valid, err = dns.check_zone_syntax(clean, new_content)
  235. if not is_valid:
  236. flash(f"Linter Error: {err}", "error")
  237. return redirect(url_for('manage_zone', domain=domain))
  238. dns.backup_zone(clean, fm.read_file(path))
  239. fm.write_file(path, new_content)
  240. subprocess.run(["rndc", "reload", clean])
  241. fm.log_audit(session['username'], clean, "Deleted a record")
  242. flash("Record deleted", "success")
  243. return redirect(url_for('manage_zone', domain=domain))
  244. @app.route('/zones/manage/<path:domain>/edit_record', methods=['POST'])
  245. @login_required
  246. def edit_record(domain):
  247. clean = "." if domain == "root_zone" else domain
  248. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  249. path, old_raw, rtype = dns.get_zone_file_path(clean), request.form.get('old_raw').strip(), request.form.get('rtype')
  250. name = dns.sanitize_record_name(request.form.get('name'), clean)
  251. val = dns.construct_record_value(request.form, rtype)
  252. lines = fm.read_file(path).split('\n')
  253. for i, l in enumerate(lines):
  254. if l.strip() == old_raw:
  255. lines[i] = f"{name}\tIN\t{rtype}\t{val}"
  256. break
  257. new_content = dns.increment_serial('\n'.join(lines))
  258. is_valid, err = dns.check_zone_syntax(clean, new_content)
  259. if not is_valid:
  260. flash(f"Linter Error: {err}", "error")
  261. return redirect(url_for('manage_zone', domain=domain))
  262. dns.backup_zone(clean, fm.read_file(path))
  263. fm.write_file(path, new_content)
  264. subprocess.run(["rndc", "reload", clean])
  265. fm.log_audit(session['username'], clean, f"Edited record {rtype} {name}")
  266. flash("Record updated", "success")
  267. return redirect(url_for('manage_zone', domain=domain))
  268. @app.route('/zones/manage/<path:domain>/rollback/<ts>', methods=['POST'])
  269. @login_required
  270. def rollback_zone(domain, ts):
  271. clean = "." if domain == "root_zone" else domain
  272. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  273. backup_file = os.path.join(config.BACKUP_DIR, f"{clean}_{ts}.db")
  274. if os.path.exists(backup_file):
  275. path = dns.get_zone_file_path(clean)
  276. dns.backup_zone(clean, fm.read_file(path))
  277. fm.write_file(path, fm.read_file(backup_file))
  278. subprocess.run(["rndc", "reload", clean])
  279. fm.log_audit(session['username'], clean, f"Rolled back to {ts}")
  280. flash("Zone rolled back", "success")
  281. else:
  282. flash("Backup not found", "error")
  283. return redirect(url_for('manage_zone', domain=domain))
  284. @app.route('/zones/manage/<path:domain>/dnssec', methods=['POST'])
  285. @login_required
  286. def toggle_dnssec(domain):
  287. clean = "." if domain == "root_zone" else domain
  288. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  289. conf = fm.read_file(config.LOCAL_CONF)
  290. block = dns.get_zone_block(conf, clean)
  291. if not block:
  292. flash("Zone configuration block not found!", "error")
  293. return redirect(url_for('manage_zone', domain=domain))
  294. if "dnssec-policy" in block:
  295. new_block = re.sub(r'\s*dnssec-policy default;\s*inline-signing yes;', '', block)
  296. fm.log_audit(session['username'], clean, "Disabled DNSSEC")
  297. else:
  298. new_block = block.replace('type master;', 'type master;\n dnssec-policy default;\n inline-signing yes;')
  299. subprocess.run(["chown", "-R", "bind:bind", config.BIND_PATH])
  300. fm.log_audit(session['username'], clean, "Enabled DNSSEC")
  301. fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block))
  302. subprocess.run(["rndc", "reconfig"])
  303. subprocess.run(["rndc", "reload", clean])
  304. return redirect(url_for('manage_zone', domain=domain))
  305. @app.route('/zones/manage/<path:domain>/edit_ips', methods=['POST'])
  306. @login_required
  307. def edit_zone_ips(domain):
  308. clean = "." if domain == "root_zone" else domain
  309. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  310. conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
  311. ips = "".join([f" {x.strip()};" for x in request.form.getlist('ips[]') if x.strip()]) or " none;"
  312. if "type slave" in block: new_block = re.sub(r'masters\s*\{.*?\};', f'masters {{{ips} }};', block, flags=re.S|re.I)
  313. elif "type forward" in block: new_block = re.sub(r'forwarders\s*\{.*?\};', f'forwarders {{{ips} }};', block, flags=re.S|re.I)
  314. fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block))
  315. subprocess.run(["rndc", "reconfig"])
  316. return redirect(url_for('manage_zone', domain=domain))
  317. @app.route('/zones/assign', methods=['POST'])
  318. @admin_required
  319. def assign_zone():
  320. d, o = request.form.get('domain'), request.form.get('owner')
  321. s = fm.load_panel_settings()
  322. s['zone_owners'][d] = o
  323. fm.save_panel_settings(s)
  324. flash("Assigned", "success")
  325. return redirect(url_for('dashboard'))
  326. @app.route('/zones/manage/<path:domain>/cluster_sync', methods=['POST'])
  327. @login_required
  328. def cluster_sync_zone(domain):
  329. clean = "." if domain == "root_zone" else domain.strip('/')
  330. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  331. conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
  332. if block and "type master" in block:
  333. cm.broadcast_to_cluster('add_slave', clean)
  334. time.sleep(1.5)
  335. subprocess.run(["rndc", "notify", clean])
  336. flash(f"Cluster Sync triggered for {clean}!", "success")
  337. else:
  338. flash("Only Master zones can be synced.", "error")
  339. return redirect(url_for('dashboard'))
  340. @app.route('/zones/manage/<path:domain>/sync')
  341. @login_required
  342. def sync_zone(domain):
  343. clean = "." if domain == "root_zone" else domain
  344. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  345. try:
  346. subprocess.run(["rndc", "retransfer", clean], check=True)
  347. flash(f"Sync initiated", "success")
  348. except:
  349. flash("Sync failed", "error")
  350. return redirect(url_for('manage_zone', domain=domain))
  351. @app.route('/zones/manage/<path:domain>/convert_to_master')
  352. @login_required
  353. def convert_to_master(domain):
  354. clean = "." if domain == "root_zone" else domain
  355. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  356. conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
  357. if not block or "type slave" not in block: return redirect(url_for('dashboard'))
  358. path_old = dns.get_zone_file_path(clean)
  359. res = subprocess.run(["named-compilezone", "-f", "raw", "-F", "text", "-o", "-", clean, path_old], capture_output=True, text=True)
  360. records_text = res.stdout if res.returncode == 0 else fm.read_file(path_old)
  361. path_new = os.path.join(config.BIND_PATH, f"db.{clean}" if clean != "." else "db.root")
  362. fm.write_file(config.LOCAL_CONF, conf.replace(block, f'zone "{clean}" {{\n type master;\n file "{path_new}";\n}};'))
  363. fm.write_file(path_new, records_text)
  364. subprocess.run(["rndc", "reconfig"])
  365. fm.log_audit(session['username'], clean, "Converted to Master")
  366. return redirect(url_for('manage_zone', domain=domain))
  367. @app.route('/zones/manage/<path:domain>/convert_to_slave', methods=['POST'])
  368. @login_required
  369. def convert_to_slave(domain):
  370. clean = "." if domain == "root_zone" else domain
  371. if not check_zone_access(clean): return redirect(url_for('dashboard'))
  372. conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean)
  373. if not block or "type master" not in block: return redirect(url_for('dashboard'))
  374. path_old = dns.get_zone_file_path(clean)
  375. path_new = os.path.join(config.CACHE_PATH, f"db.{clean}" if clean != "." else "db.root")
  376. ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()])
  377. fm.write_file(config.LOCAL_CONF, conf.replace(block, f'zone "{clean}" {{\n type slave;\n file "{path_new}";\n masters {{{ips} }};\n masterfile-format text;\n}};'))
  378. try: os.remove(path_old)
  379. except: pass
  380. subprocess.run(["rndc", "reconfig"])
  381. fm.log_audit(session['username'], clean, "Converted to Slave")
  382. return redirect(url_for('manage_zone', domain=domain))
  383. # --- ADBLOCK & ANALYTICS VIEWS ---
  384. @app.route('/adanalytics')
  385. @admin_required
  386. def adanalytics_page():
  387. s = fm.load_panel_settings()
  388. return render_template('adanalytics.html', log_enabled=(s.get('query_log_enabled') == 'yes'))
  389. @app.route('/adblock')
  390. @admin_required
  391. def adblock_dashboard():
  392. settings = fm.load_panel_settings()
  393. adblock_lists = fm.load_adblock_lists()
  394. custom_rules = fm.load_custom_rules()
  395. rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF) and 'response-policy' in fm.read_file(config.OPTIONS_FILE)
  396. return render_template('adblock.html', settings=settings, adblock_lists=adblock_lists, custom_rules=custom_rules, rpz_enabled=rpz_enabled)
  397. @app.route('/analytics')
  398. @admin_required
  399. def analytics():
  400. return render_template('analytics.html')
  401. # --- CLUSTER & SETTINGS VIEWS ---
  402. @app.route('/cluster', methods=['GET', 'POST'])
  403. @admin_required
  404. def cluster_management():
  405. settings = fm.load_panel_settings()
  406. if request.method == 'POST':
  407. action = request.form.get('action')
  408. if action == 'save_ip':
  409. settings['server_public_ip'] = request.form.get('server_public_ip', '').strip()
  410. flash("Master IP Configuration Saved", "success")
  411. elif action == 'add_node':
  412. node_url = request.form.get('node_url', '').strip()
  413. if node_url and not node_url.startswith('http'): node_url = 'http://' + node_url
  414. node_url = node_url.rstrip('/')
  415. new_node = {"name": request.form.get('node_name', '').strip(), "url": node_url, "api_key": request.form.get('node_api_key', '').strip()}
  416. if new_node['name'] and new_node['url'] and new_node['api_key']:
  417. settings['cluster_nodes'] = [n for n in settings.get('cluster_nodes', []) if n['name'].strip() != new_node['name']]
  418. settings.setdefault('cluster_nodes', []).append(new_node)
  419. flash("Cluster Node Added Successfully!", "success")
  420. elif action == 'delete_node':
  421. try:
  422. node_idx = int(request.form.get('node_index'))
  423. settings['cluster_nodes'].pop(node_idx)
  424. flash("Cluster Node Removed", "success")
  425. except: flash("Failed to remove node", "error")
  426. elif action == 'regenerate_api':
  427. settings['api_key'] = secrets.token_hex(32)
  428. flash("API Key Regenerated. Update your nodes!", "success")
  429. elif action == 'save_master':
  430. m_url = request.form.get('master_url', '').strip()
  431. if m_url and not m_url.startswith('http'): m_url = 'http://' + m_url
  432. m_url = m_url.rstrip('/')
  433. settings['master_node'] = {"name": request.form.get('master_name', '').strip(), "url": m_url}
  434. flash("Master Node Linked", "success")
  435. elif action == 'unlink_master':
  436. settings['master_node'] = {}
  437. flash("Master Node Unlinked", "success")
  438. fm.save_panel_settings(settings)
  439. return redirect(url_for('cluster_management'))
  440. return render_template('cluster.html', settings=settings)
  441. @app.route('/settings')
  442. @admin_required
  443. def settings():
  444. conf = fm.read_file(config.OPTIONS_FILE)
  445. def parse_opt(regex, default='any'):
  446. m = re.search(regex, conf, re.DOTALL | re.I)
  447. return [x.strip() for x in m.group(1).split(';') if x.strip()] if m else [default]
  448. b_opt = {
  449. 'listen_v4': parse_opt(r'listen-on\s*port\s+53\s*\{\s*(.*?)\s*\};'),
  450. 'allow_query': parse_opt(r'allow-query\s*\{\s*(.*?)\s*\};'),
  451. 'allow_transfer': parse_opt(r'allow-transfer\s*\{\s*(.*?)\s*\};', 'none'),
  452. 'also_notify': parse_opt(r'also-notify\s*\{\s*(.*?)\s*\};', ''),
  453. 'dnssec': (re.search(r'dnssec-validation\s+(.*?);', conf) or type('m',(),{'group':lambda x,y:'auto'})()).group(1)
  454. }
  455. return render_template('general_config.html', b_opt=b_opt, p_data=fm.load_panel_settings())
  456. @app.route('/settings/save', methods=['POST'])
  457. @admin_required
  458. def save_settings():
  459. def join_ips(field):
  460. vals = [x.strip() for x in request.form.getlist(field) if x.strip()]
  461. return "".join([f" {x};" for x in vals]) if vals else ""
  462. lv4 = join_ips('listen_v4[]') or " any;"
  463. aq = join_ips('allow_query[]') or " any;"
  464. at = join_ips('allow_transfer[]') or " none;"
  465. an_raw = join_ips('also_notify[]')
  466. an_line = f" also-notify {{{an_raw} }};\n" if an_raw else ""
  467. dnssec = request.form.get('dnssec', 'auto')
  468. query_log = request.form.get('query_log_enabled', 'no')
  469. log_block = ""
  470. if query_log == 'yes':
  471. subprocess.run(["mkdir", "-p", "/var/log/named"])
  472. subprocess.run(["touch", "/var/log/named/queries.log"])
  473. subprocess.run(["chown", "-R", "bind:bind", "/var/log/named"])
  474. log_block = """
  475. logging {
  476. channel queries_log {
  477. file "/var/log/named/queries.log" versions 3 size 5m;
  478. severity info;
  479. print-time yes;
  480. print-category yes;
  481. print-severity yes;
  482. };
  483. category queries { queries_log; };
  484. category xfer-in { queries_log; };
  485. category xfer-out { queries_log; };
  486. category notify { queries_log; };
  487. category rpz { queries_log; };
  488. };"""
  489. old_opts = fm.read_file(config.OPTIONS_FILE)
  490. rpz_line = ' response-policy { zone "rpz.blacklist"; };\n' if 'response-policy' in old_opts else ''
  491. opt_content = f"options {{\n directory \"/var/cache/bind\";\n listen-on port 53 {{{lv4} }};\n allow-query {{{aq} }};\n allow-transfer {{{at} }};\n{an_line}{rpz_line} dnssec-validation {dnssec};\n querylog {'yes' if query_log=='yes' else 'no'};\n auth-nxdomain no;\n}};\n{log_block}"
  492. fm.write_file(config.OPTIONS_FILE, opt_content.strip())
  493. t_names, t_refresh, t_retry, t_expire, t_ttl, t_content = request.form.getlist('t_name[]'), request.form.getlist('t_refresh[]'), request.form.getlist('t_retry[]'), request.form.getlist('t_expire[]'), request.form.getlist('t_ttl[]'), request.form.getlist('t_content[]')
  494. new_templates = {}
  495. for idx, name in enumerate(t_names):
  496. name = name.strip()
  497. if not name: continue
  498. keys, vals = request.form.getlist(f'custom_key_{idx}[]'), request.form.getlist(f'custom_val_{idx}[]')
  499. custom_vars = {keys[i].strip().upper(): vals[i] for i in range(len(keys)) if keys[i].strip()}
  500. new_templates[name] = {"refresh": t_refresh[idx], "retry": t_retry[idx], "expire": t_expire[idx], "ttl": t_ttl[idx], "content": t_content[idx], "custom_vars": custom_vars}
  501. s = fm.load_panel_settings()
  502. s.update({"templates": new_templates, "stats_retention": int(request.form.get('stats_retention', 30)), "query_log_enabled": query_log})
  503. fm.save_panel_settings(s)
  504. # -- Η ΠΡΟΣΘΗΚΗ ΠΟΥ ΕΛΕΙΠΕ: Στέλνει τα Templates στους Slaves! --
  505. cm.broadcast_templates_to_cluster(new_templates)
  506. # ---------------------------------------------------------------
  507. subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
  508. flash("Settings Saved", "success")
  509. return redirect(url_for('settings'))
  510. @app.route('/settings/view_mode', methods=['POST'])
  511. @login_required
  512. def update_view_mode():
  513. mode = request.json.get('mode', 'cards')
  514. users = fm.load_users()
  515. if session['username'] in users:
  516. users[session['username']]['view_mode'] = mode
  517. fm.save_users(users)
  518. return jsonify({"status": "success"})
  519. # --- USERS VIEWS ---
  520. @app.route('/users')
  521. @admin_required
  522. def manage_users():
  523. return render_template('users.html', users=fm.load_users(), all_templates=list(fm.load_panel_settings()['templates'].keys()))
  524. @app.route('/users/save', methods=['POST'])
  525. @admin_required
  526. def save_user():
  527. action, old_u, u, p, r = request.form.get('action'), request.form.get('old_username'), request.form.get('username'), request.form.get('password'), request.form.get('role')
  528. assigned = request.form.getlist('assigned_templates[]')
  529. users = fm.load_users()
  530. if action == 'add':
  531. if u in users: flash("User exists", "error")
  532. else:
  533. users[u] = {"password": generate_password_hash(p), "role": r, "assigned_templates": assigned, "view_mode": "cards"}
  534. cm.broadcast_user_to_cluster('save', u, users[u]) # CLUSTER SYNC
  535. elif action == 'edit' and old_u in users:
  536. pw = generate_password_hash(p) if p else users[old_u]['password']
  537. users[old_u] = {"password": pw, "role": r, "assigned_templates": assigned, "view_mode": users[old_u].get('view_mode','cards'), "api_token": users[old_u].get('api_token')}
  538. if u != old_u:
  539. users[u] = users.pop(old_u)
  540. cm.broadcast_user_to_cluster('save', u, users[u], old_username=old_u if u != old_u else None) # CLUSTER SYNC
  541. fm.save_users(users)
  542. flash("User saved", "success")
  543. return redirect(url_for('manage_users'))
  544. @app.route('/users/delete/<username>')
  545. @admin_required
  546. def delete_user(username):
  547. users = fm.load_users()
  548. if username != 'admin' and username in users:
  549. del users[username]
  550. fm.save_users(users)
  551. cm.broadcast_user_to_cluster('delete', username) # CLUSTER SYNC
  552. flash("User deleted", "success")
  553. return redirect(url_for('manage_users'))
  554. # --- ADBLOCK API (MASTER) ---
  555. @app.route('/api/adblock/update', methods=['POST'])
  556. @admin_required
  557. def route_update_adblock():
  558. result = ab.generate_and_apply_rpz()
  559. if result.get("status") == "success":
  560. # BUG FIX: Ειδοποιούμε το Cluster ότι κάναμε update!
  561. cm.broadcast_adblock_to_cluster('enable')
  562. return jsonify(result)
  563. else:
  564. return jsonify(result), 500
  565. @app.route('/api/adblock/disable', methods=['POST'])
  566. @admin_required
  567. def route_disable_adblock():
  568. result = ab.disable_rpz()
  569. cm.broadcast_adblock_to_cluster('disable')
  570. return jsonify(result)
  571. @app.route('/api/adblock/sync_cluster', methods=['POST'])
  572. @admin_required
  573. def route_sync_adblock_cluster():
  574. rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF)
  575. action = 'enable' if rpz_enabled else 'disable'
  576. settings = fm.load_panel_settings()
  577. nodes = settings.get('cluster_nodes', [])
  578. lists = fm.load_adblock_lists()
  579. rules = fm.load_custom_rules()
  580. count = settings.get('adblock_domains_count', 0)
  581. update_time = settings.get('adblock_last_update', 'Never')
  582. success_count = 0
  583. for node in nodes:
  584. try:
  585. url = f"{node['url'].rstrip('/')}/api/v1/cluster/adblock"
  586. headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"}
  587. payload = {"action": action, "lists": lists, "rules": rules, "count": count, "time": update_time}
  588. proxy_handler = urllib.request.ProxyHandler({})
  589. opener = urllib.request.build_opener(proxy_handler)
  590. req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST')
  591. with opener.open(req, timeout=10) as response:
  592. if response.status == 200: success_count += 1
  593. except Exception as e:
  594. print(f"Cluster AdBlock Sync FAIL: {node['name']} -> {e}")
  595. return jsonify({"status": "success", "message": f"Sent AdBlock config to {success_count}/{len(nodes)} cluster nodes."})
  596. @app.route('/api/v1/cluster/templates', methods=['POST'])
  597. @api_required
  598. def api_cluster_save_templates():
  599. new_templates = request.get_json(force=True, silent=True)
  600. if new_templates is None:
  601. return jsonify({"status": "error", "message": "Invalid templates data"}), 400
  602. s = fm.load_panel_settings()
  603. s['templates'] = new_templates
  604. fm.save_panel_settings(s)
  605. return jsonify({"status": "success", "message": "Templates synchronized"})
  606. @app.route('/api/adblock/toggle_list', methods=['POST'])
  607. @admin_required
  608. def toggle_adblock_list():
  609. list_id = request.json.get('id')
  610. state = request.json.get('state')
  611. lists = fm.load_adblock_lists()
  612. for l in lists:
  613. if l['id'] == list_id:
  614. l['enabled'] = state
  615. break
  616. fm.save_adblock_lists(lists)
  617. return jsonify({"status": "success"})
  618. @app.route('/api/adblock/add_custom', methods=['POST'])
  619. @admin_required
  620. def add_custom_adblock():
  621. name = request.form.get('name', '').strip()
  622. url = request.form.get('url', '').strip()
  623. desc = request.form.get('desc', 'Custom User Blocklist').strip()
  624. if not name or not url: return redirect(url_for('adblock_dashboard'))
  625. lists = fm.load_adblock_lists()
  626. new_id = "custom_" + str(int(time.time()))
  627. lists.append({"id": new_id, "name": name, "desc": desc, "url": url, "enabled": True, "category": "Custom", "is_custom": True, "info": url})
  628. fm.save_adblock_lists(lists)
  629. flash("Custom list added", "success")
  630. return redirect(url_for('adblock_dashboard'))
  631. @app.route('/api/adblock/edit_custom', methods=['POST'])
  632. @admin_required
  633. def edit_custom_adblock():
  634. list_id = request.form.get('list_id')
  635. name = request.form.get('name', '').strip()
  636. url = request.form.get('url', '').strip()
  637. desc = request.form.get('desc', '').strip()
  638. lists = fm.load_adblock_lists()
  639. for l in lists:
  640. if l['id'] == list_id and l.get('is_custom'):
  641. l['name'], l['desc'], l['url'], l['info'] = name, desc, url, url
  642. break
  643. fm.save_adblock_lists(lists)
  644. flash("Custom list updated", "success")
  645. return redirect(url_for('adblock_dashboard'))
  646. @app.route('/api/adblock/delete_list/<list_id>', methods=['GET'])
  647. @admin_required
  648. def delete_adblock_list(list_id):
  649. lists = [l for l in fm.load_adblock_lists() if l['id'] != list_id or not l['is_custom']]
  650. fm.save_adblock_lists(lists)
  651. flash("List deleted", "success")
  652. return redirect(url_for('adblock_dashboard'))
  653. @app.route('/api/adblock/rules/add', methods=['POST'])
  654. @admin_required
  655. def add_adblock_rule():
  656. rtype, domain = request.form.get('type'), request.form.get('domain', '').strip().lower()
  657. domain = re.sub(r'^https?://', '', domain).rstrip('/')
  658. rules = fm.load_custom_rules()
  659. if domain not in rules[rtype]: rules[rtype].append(domain)
  660. fm.save_custom_rules(rules)
  661. return jsonify({"status": "success"})
  662. @app.route('/api/adblock/rules/edit', methods=['POST'])
  663. @admin_required
  664. def edit_adblock_rule():
  665. rtype = request.form.get('type')
  666. old_domain = request.form.get('old_domain', '').strip()
  667. new_domain = re.sub(r'^https?://', '', request.form.get('new_domain', '').strip().lower()).rstrip('/')
  668. rules = fm.load_custom_rules()
  669. if old_domain in rules.get(rtype, []):
  670. rules[rtype][rules[rtype].index(old_domain)] = new_domain
  671. fm.save_custom_rules(rules)
  672. return jsonify({"status": "success"})
  673. @app.route('/api/adblock/rules/delete', methods=['POST'])
  674. @admin_required
  675. def delete_adblock_rule():
  676. rtype, domain = request.form.get('type'), request.form.get('domain', '').strip()
  677. rules = fm.load_custom_rules()
  678. rules[rtype] = [d for d in rules.get(rtype, []) if d != domain]
  679. fm.save_custom_rules(rules)
  680. return jsonify({"status": "success"})
  681. # --- STATS API ---
  682. @app.route('/api/adblock/stats_live')
  683. @admin_required
  684. def api_adblock_stats_live():
  685. with stats.stats_lock:
  686. try:
  687. with open(config.STATS_FILE, "r") as f: st = json.load(f)
  688. except: st = {}
  689. now = datetime.datetime.now().strftime("%Y-%m-%d")
  690. day_data = st.get(now, {})
  691. total_q = day_data.get("queries", 0)
  692. total_b = day_data.get("blocked_queries", 0)
  693. top_domains = sorted(day_data.get("blocked_domains", {}).items(), key=lambda x: x[1], reverse=True)[:10]
  694. top_clients = sorted(day_data.get("blocked_clients", {}).items(), key=lambda x: x[1], reverse=True)[:10]
  695. percentage = round((total_b / total_q * 100), 2) if total_q > 0 else 0
  696. return jsonify({
  697. "total_queries": total_q,
  698. "total_blocked": total_b,
  699. "percentage": percentage,
  700. "top_domains": top_domains,
  701. "top_clients": top_clients,
  702. "timeline": day_data.get("timeline", {}),
  703. "block_timeline": day_data.get("block_timeline", {})
  704. })
  705. @app.route('/api/stats')
  706. @admin_required
  707. def api_stats():
  708. with stats.stats_lock: return fm.read_file(config.STATS_FILE) or "{}"
  709. @app.route('/api/ip_queries/<ip>')
  710. @admin_required
  711. def get_ip_queries(ip):
  712. if not re.match(r'^[\d\.\:a-fA-F]+$', ip): return jsonify({"error": "Invalid IP format"})
  713. try:
  714. cmd = f"grep -i '{ip}#' {config.QUERY_LOG_PATH} | tail -n 50"
  715. proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)
  716. lines = proc.stdout.strip().split('\n')
  717. queries = []
  718. for line in reversed(lines):
  719. if not line: continue
  720. m = re.search(r'query:\s+([^\s]+)\s+IN\s+([A-Z0-9]+)', line, re.I)
  721. if m:
  722. time_str = " ".join(line.split()[:2]) if len(line.split()) > 2 else ""
  723. queries.append({"time": time_str, "domain": m.group(1), "type": m.group(2)})
  724. return jsonify({"ip": ip, "queries": queries})
  725. except Exception as e: return jsonify({"error": str(e)})
  726. # --- UTILS API ---
  727. @app.route('/api/zones/test_dig', methods=['POST'])
  728. @login_required
  729. def test_dig_transfer():
  730. domain, target_ip = request.json.get('domain'), request.json.get('target_ip', '127.0.0.1')
  731. if not domain: return jsonify({"error": "No domain provided"}), 400
  732. block = dns.get_zone_block(fm.read_file(config.LOCAL_CONF), domain)
  733. if block:
  734. m = re.search(r'masters\s*\{\s*([^;]+);', block)
  735. if m: target_ip = m.group(1).strip()
  736. try:
  737. output = subprocess.run(["dig", f"@{target_ip}", domain, "AXFR"], capture_output=True, text=True, timeout=8).stdout
  738. if "Transfer failed" in output or "connection refused" in output.lower() or not output.strip():
  739. return jsonify({"success": False, "raw": output, "message": f"Transfer failed from Master ({target_ip})"})
  740. analysis, total_count = {}, 0
  741. for line in output.split('\n'):
  742. if not line or line.startswith((';', '\n')): continue
  743. for p in line.split():
  744. if p in ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'SRV', 'PTR']:
  745. analysis[p] = analysis.get(p, 0) + 1
  746. total_count += 1
  747. break
  748. return jsonify({"success": True, "total": total_count, "analysis": analysis, "raw": output, "target": target_ip})
  749. except Exception as e: return jsonify({"success": False, "message": str(e)})
  750. @app.route('/api/service/status')
  751. @admin_required
  752. def get_service_status():
  753. try:
  754. res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True)
  755. is_active = res.stdout.strip() == "active"
  756. uptime = "Unknown"
  757. if is_active:
  758. res_up = subprocess.run(["systemctl", "show", config.BIND_SERVICE, "--property=ActiveEnterTimestamp"], capture_output=True, text=True)
  759. uptime = res_up.stdout.replace("ActiveEnterTimestamp=", "").strip()
  760. return jsonify({"status": "active" if is_active else "inactive", "uptime": uptime})
  761. except: return jsonify({"status": "error", "uptime": "N/A"})
  762. @app.route('/service/<action>')
  763. @admin_required
  764. def service_control(action):
  765. try:
  766. if action == 'reload': subprocess.run(["rndc", "reload"], check=True)
  767. else: subprocess.run(["systemctl", action, config.BIND_SERVICE], check=True)
  768. except: pass
  769. return redirect(request.referrer or url_for('dashboard'))
  770. @app.route('/backup/export')
  771. @admin_required
  772. def export_backup():
  773. memory_file = io.BytesIO()
  774. zip_f = zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED)
  775. core_files = [
  776. config.LOCAL_CONF, config.OPTIONS_FILE, config.PANEL_SETTINGS,
  777. config.USERS_FILE, config.STATS_FILE, config.ADBLOCK_LISTS_FILE,
  778. config.ADBLOCK_CUSTOM_RULES_FILE
  779. ]
  780. for f in core_files:
  781. if os.path.exists(f):
  782. zip_f.write(f, os.path.basename(f))
  783. # BUG FIX: Διπλός έλεγχος os.path.exists() για να μην κρασάρει το zip
  784. for f in os.listdir(config.BIND_PATH):
  785. if f.startswith("db.") and f != "db.rpz.blacklist":
  786. full_path = os.path.join(config.BIND_PATH, f)
  787. if os.path.exists(full_path):
  788. zip_f.write(full_path, f)
  789. zip_f.close()
  790. memory_file.seek(0)
  791. return send_file(memory_file, download_name=f"bind_backup_{time.strftime('%Y%m%d_%H%M%S')}.zip", as_attachment=True)
  792. @app.route('/backup/import', methods=['POST'])
  793. @admin_required
  794. def import_backup():
  795. file = request.files.get('backup_file')
  796. if file and file.filename.endswith('.zip'):
  797. with zipfile.ZipFile(file, 'r') as zf: zf.extractall(config.BIND_PATH)
  798. subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
  799. flash("Restored", "success")
  800. return redirect(url_for('dashboard'))
  801. # --- CLUSTER SLAVE RECEIVERS (V1 API) ---
  802. @app.route('/api/v1/cluster/adblock', methods=['POST'])
  803. @api_required
  804. def api_cluster_adblock():
  805. data = request.json
  806. action = data.get('action')
  807. master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
  808. if 'lists' in data: fm.save_adblock_lists(data['lists'])
  809. if 'rules' in data: fm.save_custom_rules(data['rules'])
  810. s = fm.load_panel_settings()
  811. if 'count' in data: s['adblock_domains_count'] = data['count']
  812. if 'time' in data: s['adblock_last_update'] = data['time']
  813. fm.save_panel_settings(s)
  814. conf, opts = fm.read_file(config.LOCAL_CONF), fm.read_file(config.OPTIONS_FILE)
  815. if action == 'enable':
  816. if 'zone "rpz.blacklist"' not in conf:
  817. path = os.path.join(config.CACHE_PATH, "db.rpz.blacklist")
  818. conf += f'\nzone "rpz.blacklist" {{\n type slave;\n file "{path}";\n masters {{ {master_ip}; }};\n allow-query {{ any; }};\n}};\n'
  819. fm.write_file(config.LOCAL_CONF, conf)
  820. if "response-policy" not in opts:
  821. opts = re.sub(r'(options\s*\{)', r'\1\n response-policy { zone "rpz.blacklist"; };', opts)
  822. fm.write_file(config.OPTIONS_FILE, opts)
  823. else:
  824. block = dns.get_zone_block(conf, "rpz.blacklist")
  825. if block: fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
  826. opts = re.sub(r'\s*response-policy\s*\{\s*zone\s*"rpz\.blacklist";\s*\};\s*', '\n', opts)
  827. fm.write_file(config.OPTIONS_FILE, opts)
  828. subprocess.run(["systemctl", "restart", config.BIND_SERVICE])
  829. return jsonify({"status": "success", "message": "RPZ synced on slave."})
  830. @app.route('/api/v1/cluster/zone', methods=['POST'])
  831. @api_required
  832. def api_add_slave():
  833. data = request.get_json(force=True, silent=True) or {}
  834. domain = data.get('domain')
  835. if not domain:
  836. return jsonify({"status": "error", "message": "No domain provided"}), 400
  837. domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', domain).replace('../', '')
  838. masters_list = data.get('masters', [])
  839. if not masters_list:
  840. master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
  841. masters_list = [master_ip]
  842. masters_string = "".join([f" {ip};" for ip in masters_list])
  843. conf = fm.read_file(config.LOCAL_CONF)
  844. # --- OVERWRITE LOGIC ---
  845. old_block = dns.get_zone_block(conf, domain)
  846. if old_block:
  847. conf = conf.replace(old_block, "")
  848. message = f"Zone {domain} overwritten"
  849. else:
  850. message = f"Zone {domain} added"
  851. # -----------------------
  852. path = os.path.join(config.CACHE_PATH, f"db.{domain}")
  853. new_zone = f'\nzone "{domain}" {{\n type slave;\n file "{path}";\n masters {{{masters_string} }};\n masterfile-format text;\n}};\n'
  854. fm.write_file(config.LOCAL_CONF, (conf.strip() + "\n" + new_zone).strip() + "\n")
  855. subprocess.run(["rndc", "reconfig"])
  856. return jsonify({"status": "success", "message": message})
  857. @app.route('/api/v1/cluster/zone/<domain>', methods=['DELETE'])
  858. @api_required
  859. def api_delete_zone(domain):
  860. clean = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', domain).replace('../', '')
  861. conf = fm.read_file(config.LOCAL_CONF)
  862. block = dns.get_zone_block(conf, clean)
  863. if not block: return jsonify({"status": "error", "message": "Zone not found"}), 404
  864. path = dns.get_zone_file_path(clean)
  865. fm.write_file(config.LOCAL_CONF, conf.replace(block, ""))
  866. subprocess.run(["rndc", "reconfig"])
  867. try: os.remove(path)
  868. except: pass
  869. fm.log_audit("API_SYNC", clean, "Auto-deleted via Cluster Sync")
  870. return jsonify({"status": "success", "message": f"Zone {clean} deleted."})
  871. @app.route('/api/v1/cluster/ping', methods=['GET'])
  872. @api_required
  873. def api_ping():
  874. return jsonify({"status": "success", "message": "Node is synchronized"})
  875. @app.route('/cluster/check_node', methods=['POST'])
  876. @login_required
  877. def check_cluster_node():
  878. node_name = request.json.get('node_name')
  879. node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == node_name), None)
  880. if not node: return jsonify({"status": "error"})
  881. try:
  882. url = f"{node['url'].rstrip('/')}/api/v1/cluster/ping"
  883. proxy_handler = urllib.request.ProxyHandler({})
  884. opener = urllib.request.build_opener(proxy_handler)
  885. req = urllib.request.Request(url, headers={"X-API-Key": node['api_key']})
  886. with opener.open(req, timeout=10) as response:
  887. if response.status == 200: return jsonify({"status": "online"})
  888. except urllib.error.HTTPError as e:
  889. print(f"\n[CLUSTER HTTP ERROR] Node: {node_name} -> Κωδικός: {e.code}\n")
  890. if e.code == 401: return jsonify({"status": "auth_error"})
  891. except Exception as e:
  892. print(f"\n[CLUSTER NETWORK ERROR] Node: {node_name} -> Αιτία: {str(e)}\n")
  893. return jsonify({"status": "offline"})
  894. @app.route('/api/v1/cluster/user', methods=['POST'])
  895. @api_required
  896. def api_cluster_save_user():
  897. data = request.get_json(force=True, silent=True) or {}
  898. username, user_data, old_username = data.get('username'), data.get('user_data'), data.get('old_username')
  899. if not username or not user_data:
  900. return jsonify({"status": "error", "message": "Missing user data"}), 400
  901. users = fm.load_users()
  902. if old_username and old_username in users and old_username != username:
  903. del users[old_username]
  904. users[username] = user_data
  905. fm.save_users(users)
  906. return jsonify({"status": "success", "message": f"User {username} synced"})
  907. @app.route('/api/v1/cluster/user/<username>', methods=['DELETE'])
  908. @api_required
  909. def api_cluster_delete_user(username):
  910. users = fm.load_users()
  911. if username in users and username != 'admin':
  912. del users[username]
  913. fm.save_users(users)
  914. return jsonify({"status": "success", "message": f"User {username} deleted"})
  915. # --- REMOTE SERVICE CONTROL (SLAVE RECEIVER) ---
  916. @app.route('/api/v1/cluster/service', methods=['POST'])
  917. @api_required
  918. def api_cluster_service():
  919. data = request.get_json(force=True, silent=True) or {}
  920. action = data.get('action')
  921. if action in ['start', 'stop', 'restart', 'reload']:
  922. try:
  923. res = subprocess.run(["systemctl", action, config.BIND_SERVICE], capture_output=True, text=True)
  924. if res.returncode == 0:
  925. return jsonify({"status": "success", "message": f"Service {action}ed successfully"})
  926. else:
  927. return jsonify({"status": "error", "message": f"Systemctl failed: {res.stderr.strip()}"})
  928. except Exception as e:
  929. return jsonify({"status": "error", "message": str(e)})
  930. elif action == 'status':
  931. try:
  932. res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True)
  933. is_active = res.stdout.strip() == "active"
  934. return jsonify({"status": "success", "service_status": "active" if is_active else "inactive"})
  935. except:
  936. return jsonify({"status": "error", "service_status": "unknown"})
  937. return jsonify({"status": "error", "message": "Invalid action provided"})
  938. # --- REMOTE SERVICE PROXY (MASTER TO SLAVE) ---
  939. @app.route('/cluster/node_service', methods=['POST'])
  940. @login_required
  941. def cluster_node_service():
  942. import urllib.request, urllib.error
  943. try:
  944. data = request.get_json(force=True, silent=True) or {}
  945. node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == data.get('node_name')), None)
  946. if not node: return jsonify({"status": "error", "message": "Node not found."})
  947. url = f"{node['url'].rstrip('/')}/api/v1/cluster/service"
  948. req = urllib.request.Request(url, data=json.dumps({"action": data.get('action')}).encode('utf-8'), headers={"X-API-Key": node['api_key'], "Content-Type": "application/json"}, method='POST')
  949. proxy_handler = urllib.request.ProxyHandler({})
  950. https_handler = urllib.request.HTTPSHandler(context=config.ssl_ctx)
  951. opener = urllib.request.build_opener(proxy_handler, https_handler)
  952. with opener.open(req, timeout=15) as response:
  953. return jsonify(json.loads(response.read().decode('utf-8')))
  954. except urllib.error.HTTPError as e:
  955. return jsonify({"status": "error", "message": f"Slave HTTP Error {e.code}."})
  956. except Exception as e:
  957. return jsonify({"status": "error", "message": f"Connection failed: {str(e)}"})
  958. # --- GLOBAL CLUSTER SYNC (MASTER ACTION) ---
  959. @app.route('/api/cluster/sync_all', methods=['POST'])
  960. @admin_required
  961. def sync_all_cluster():
  962. conf = fm.read_file(config.LOCAL_CONF)
  963. import re
  964. zones = re.findall(r'zone\s+"([^"]+)"', conf)
  965. ignore = ["rpz.blacklist", ".", "localhost", "127.in-addr.arpa", "0.in-addr.arpa", "255.in-addr.arpa"]
  966. valid_zones = [z for z in zones if z not in ignore]
  967. def bulk_sync():
  968. import urllib.request, json, ssl, time
  969. settings = fm.load_panel_settings()
  970. nodes = settings.get('cluster_nodes', [])
  971. users = fm.load_users()
  972. ctx = ssl.create_default_context()
  973. ctx.check_hostname = False
  974. ctx.verify_mode = ssl.CERT_NONE
  975. for node in nodes:
  976. # 1. Sync Users First
  977. for u_name, u_data in users.items():
  978. try:
  979. url = f"{node['url'].rstrip('/')}/api/v1/cluster/user"
  980. payload = {"username": u_name, "user_data": u_data}
  981. req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers={"X-API-Key": node['api_key'], "Content-Type": "application/json"}, method='POST')
  982. urllib.request.urlopen(req, timeout=5, context=ctx)
  983. except Exception as e:
  984. print(f"Cluster Sync FAIL: {node['name']} -> User {u_name} ({e})")
  985. # 2. Sync Zones
  986. for z in valid_zones:
  987. masters = dns.get_zone_masters_list(z)
  988. if not masters:
  989. master_ip = settings.get('server_public_ip', '').strip()
  990. if master_ip: masters = [master_ip]
  991. try:
  992. url = f"{node['url'].rstrip('/')}/api/v1/cluster/zone"
  993. headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"}
  994. payload = {"domain": z, "masters": masters}
  995. req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST')
  996. urllib.request.urlopen(req, timeout=5, context=ctx)
  997. print(f"Cluster Sync OK: {node['name']} -> {z}")
  998. except Exception as e:
  999. print(f"Cluster Sync FAIL: {node['name']} -> {z} ({e})")
  1000. time.sleep(0.5)
  1001. import threading
  1002. threading.Thread(target=bulk_sync, daemon=True).start()
  1003. return jsonify({"status": "success", "message": f"Global sync started! Sending {len(fm.load_users())} users and {len(valid_zones)} zones in the background."})
  1004. # --- USER API & TOKEN ROUTES ---
  1005. @app.route('/settings/generate_token', methods=['POST'])
  1006. @login_required
  1007. def generate_user_token():
  1008. users = fm.load_users()
  1009. new_token = secrets.token_urlsafe(32)
  1010. users[session['username']]['api_token'] = new_token
  1011. fm.save_users(users)
  1012. cm.broadcast_user_to_cluster('save', session['username'], users[session['username']]) # CLUSTER SYNC
  1013. flash("New API Token generated successfully!", "success")
  1014. return redirect(url_for('profile'))
  1015. @app.route('/api/v1/user/zone/check/<domain>', methods=['GET'])
  1016. @user_api_required
  1017. def api_user_check_zone(domain):
  1018. conf = fm.read_file(config.LOCAL_CONF)
  1019. exists = f'zone "{domain}"' in conf
  1020. return jsonify({"status": "success", "exists": exists, "domain": domain})
  1021. # --- 3. API CREATE ZONE (Update) ---
  1022. @app.route('/api/v1/user/zone/create', methods=['POST'])
  1023. @user_api_required
  1024. def api_user_create_zone():
  1025. data = request.get_json(force=True, silent=True) or {}
  1026. domain = data.get('domain', '').strip()
  1027. t_name = data.get('template_name')
  1028. z_type = data.get('type', 'master')
  1029. if not domain: return jsonify({"status": "error", "message": "Domain is required"}), 400
  1030. conf = fm.read_file(config.LOCAL_CONF)
  1031. if f'zone "{domain}"' in conf:
  1032. return jsonify({"status": "error", "message": "Zone already exists"}), 409
  1033. if z_type == 'master':
  1034. # MANDATORY TEMPLATE CHECK
  1035. if not t_name:
  1036. return jsonify({"status": "error", "message": "template_name is mandatory for Master zones"}), 400
  1037. if session['role'] != 'admin':
  1038. assigned = fm.load_users().get(session['username'], {}).get('assigned_templates', [])
  1039. if t_name not in assigned:
  1040. return jsonify({"status": "error", "message": "Template access denied"}), 403
  1041. t = fm.load_panel_settings()['templates'].get(t_name)
  1042. if not t:
  1043. return jsonify({"status": "error", "message": "Template not found"}), 404
  1044. path = os.path.join(config.BIND_PATH, f"db.{domain}")
  1045. new_conf = conf + f'\nzone "{domain}" {{\n type master;\n file "{path}";\n}};\n'
  1046. fm.write_file(path, dns.apply_template(t['content'], domain, t))
  1047. else:
  1048. # (Αν θελήσεις αργότερα να επιτρέπεις στο API να φτιάχνει και slaves)
  1049. return jsonify({"status": "error", "message": "Only Master zones are supported via this API endpoint currently."}), 400
  1050. fm.write_file(config.LOCAL_CONF, new_conf)
  1051. s = fm.load_panel_settings()
  1052. s['zone_owners'][domain] = session['username']
  1053. fm.save_panel_settings(s)
  1054. subprocess.run(["rndc", "reconfig"])
  1055. if data.get('sync', True): cm.broadcast_to_cluster('add_slave', domain)
  1056. return jsonify({"status": "success", "message": f"Zone {domain} created"})
  1057. @app.route('/api/v1/user/record/add', methods=['POST'])
  1058. @user_api_required
  1059. def api_user_add_record():
  1060. data = request.get_json(force=True, silent=True) or {}
  1061. domain = data.get('domain')
  1062. rtype = data.get('type', 'A').upper()
  1063. name = data.get('name')
  1064. value = data.get('value')
  1065. if not check_zone_access(domain):
  1066. return jsonify({"status": "error", "message": "Access denied to this zone"}), 403
  1067. path = dns.get_zone_file_path(domain)
  1068. content = fm.read_file(path)
  1069. # Sanitization and proper formatting (e.g. TXT quotes, CNAME dots)
  1070. clean_name = dns.sanitize_record_name(name, domain)
  1071. val = dns.construct_record_value({'value': value}, rtype)
  1072. # Check if exists
  1073. if f"{clean_name}\tIN\t{rtype}\t{val}" in content:
  1074. return jsonify({"status": "error", "message": "Record already exists"}), 409
  1075. new_content = dns.increment_serial(content) + f"{clean_name}\tIN\t{rtype}\t{val}\n"
  1076. is_valid, err = dns.check_zone_syntax(domain, new_content)
  1077. if not is_valid: return jsonify({"status": "error", "message": f"Invalid syntax: {err}"}), 400
  1078. fm.write_file(path, new_content)
  1079. subprocess.run(["rndc", "reload", domain])
  1080. if data.get('sync', True): cm.broadcast_to_cluster('add_slave', domain)
  1081. return jsonify({"status": "success", "message": "Record added"})
  1082. @app.route('/api/v1/user/sync', methods=['POST'])
  1083. @user_api_required
  1084. def api_user_sync_cluster():
  1085. data = request.get_json(force=True, silent=True) or {}
  1086. domain = data.get('domain')
  1087. if not check_zone_access(domain): return jsonify({"status": "error", "message": "Access denied"}), 403
  1088. cm.broadcast_to_cluster('add_slave', domain)
  1089. return jsonify({"status": "success", "message": "Sync command sent"})
  1090. @app.route('/api/v1/user/templates', methods=['GET'])
  1091. @user_api_required
  1092. def api_user_get_templates():
  1093. users = fm.load_users()
  1094. assigned = users.get(session['username'], {}).get('assigned_templates', [])
  1095. all_templates = fm.load_panel_settings().get('templates', {})
  1096. if session['role'] == 'admin':
  1097. user_templates = list(all_templates.keys())
  1098. else:
  1099. user_templates = [t for t in assigned if t in all_templates]
  1100. return jsonify({"status": "success", "templates": user_templates})
  1101. if __name__ == '__main__':
  1102. # Είναι σημαντικό το threaded=True για να μην κάνει "block" το UI στο self-ping
  1103. app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)