app.py 59 KB

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