from flask import Flask, render_template, request, redirect, flash, url_for, jsonify, send_file, session from werkzeug.security import check_password_hash, generate_password_hash from functools import wraps import subprocess, os, re, time, json, zipfile, io, datetime, urllib.request, secrets import config from core import file_manager as fm from core import dns_manager as dns from core import adblock_manager as ab from core import cluster_manager as cm from core import stats_manager as stats app = Flask(__name__) app.secret_key = "bind_pro_v108_modular_masterpiece" # Εκκίνηση του Background Daemon για τα Analytics stats.start_stats_daemon() # --- AUTO VERSIONING ΑΠΟ ΤΟ GIT --- try: # Διαβάζει το τελευταίο commit hash από το git (π.χ. "c0ad2a4") git_hash = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL).decode().strip() APP_VERSION = f"v1.0.8 (Build {git_hash})" except: APP_VERSION = "v1.0.8" @app.context_processor def inject_version(): # Αυτό κάνει τη μεταβλητή {{ app_version }} διαθέσιμη σε ΟΛΑ τα HTML templates! return dict(app_version=APP_VERSION) # --- DECORATORS & AUTH --- def login_required(f): @wraps(f) def dec(*args, **kwargs): if 'username' not in session: return redirect(url_for('login')) return f(*args, **kwargs) return dec def admin_required(f): @wraps(f) def dec(*args, **kwargs): if session.get('role') != 'admin': return redirect(url_for('dashboard')) return f(*args, **kwargs) return dec def api_required(f): @wraps(f) def dec(*args, **kwargs): key = request.headers.get('X-API-Key') if not key or key != fm.load_panel_settings().get('api_key'): return jsonify({"status": "error", "message": "Unauthorized"}), 401 return f(*args, **kwargs) return dec # --- USER API AUTH DECORATOR --- def user_api_required(f): @wraps(f) def dec(*args, **kwargs): token = request.headers.get('Authorization') # Expected: Bearer # FIX: Ασφαλές split για να μην πετάει IndexError 500 if token and token.startswith('Bearer ') and len(token.split(' ')) > 1: token = token.split(' ')[1] users = fm.load_users() for username, data in users.items(): if data.get('api_token') == token: session['username'] = username session['role'] = data.get('role') return f(*args, **kwargs) return jsonify({"status": "error", "message": "Invalid or missing API Token"}), 401 return dec def check_zone_access(domain): if session.get('role') == 'admin': return True return fm.load_panel_settings().get('zone_owners', {}).get(domain) == session.get('username') # --- AUTH ROUTES --- @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': u, p = request.form.get('username'), request.form.get('password') users = fm.load_users() if u in users and check_password_hash(users[u]['password'], p): session.update({'username': u, 'role': users[u]['role']}) return redirect(url_for('dashboard')) flash("Invalid Credentials", "error") return render_template('login.html') @app.route('/logout') def logout(): session.clear() return redirect(url_for('login')) @app.route('/profile') @login_required def profile(): users = fm.load_users() user_data = users.get(session['username'], {}) return render_template('profile.html', user_data=user_data) # --- MAIN DASHBOARD & ZONES --- @app.route('/') @login_required def dashboard(): conf = fm.read_file(config.LOCAL_CONF) zones_raw = re.findall(r'zone\s+"([^"]+)"\s*\{.*?type\s+(master|slave|forward|hint);', conf, re.DOTALL) zones_raw = [z for z in zones_raw if z[0] != "rpz.blacklist"] settings = fm.load_panel_settings() all_templates = settings.get('templates', {}) owners = settings.get('zone_owners', {}) users = fm.load_users() current_user_data = users.get(session['username'], {}) # Φιλτράρισμα Ζωνών & Templates if session['role'] == 'admin': zones = zones_raw available_templates = all_templates else: zones = [z for z in zones_raw if owners.get(z[0]) == session['username']] assigned_keys = current_user_data.get('assigned_templates', []) available_templates = {k: v for k, v in all_templates.items() if k in assigned_keys} try: status = subprocess.check_output(["systemctl", "is-active", config.BIND_SERVICE]).decode().strip() except: status = "inactive" view_mode = users.get(session['username'], {}).get('view_mode', 'cards') return render_template('dashboard.html', zones=zones, status=status, templates=available_templates, owners=owners, all_usernames=list(users.keys()), view_mode=view_mode) @app.route('/zones/add', methods=['POST']) @login_required def add_zone(): raw_domain = request.form.get('domain', '').strip() domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', raw_domain).replace('../', '') z_type, direction = request.form.get('type'), request.form.get('direction', 'forward') if z_type != "hint" and direction == 'reverse' and not domain.endswith('.arpa'): domain = re.sub(r'/.*$', '', domain) parts = domain.strip('.').split('.') if len(parts) == 4 and parts[3] == '0': parts = parts[:3] domain = ".".join(reversed(parts)) + ".in-addr.arpa" if z_type == "hint": domain = "." conf = fm.read_file(config.LOCAL_CONF) default_zones = fm.read_file(os.path.join(config.BIND_PATH, "named.conf.default-zones")) if f'zone "{domain}"' in conf or f'zone "{domain}"' in default_zones: flash(f"Zone '{domain}' exists!", "error") return redirect(url_for('dashboard')) if z_type == "hint": hint_path = os.path.join(config.BIND_PATH, "db.root") try: urllib.request.urlretrieve("https://www.internic.net/domain/named.root", hint_path) subprocess.run(["chown", "bind:bind", hint_path]) except: fm.write_file(hint_path, ". 3600000 IN NS A.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET. 3600000 A 198.41.0.4") conf += f'\nzone "." {{\n type hint;\n file "{hint_path}";\n}};\n' elif z_type == "master": path = os.path.join(config.BIND_PATH, f"db.{domain}") t_name = request.form.get('template_name') # MANDATORY TEMPLATE CHECK if not t_name: flash("Template is mandatory for Master zones!", "error") return redirect(url_for('dashboard')) if session['role'] != 'admin': assigned = fm.load_users().get(session['username'], {}).get('assigned_templates', []) if t_name not in assigned: flash("Access denied to this template!", "error") return redirect(url_for('dashboard')) t = fm.load_panel_settings()['templates'].get(t_name) if not t: flash("Template not found!", "error") return redirect(url_for('dashboard')) conf += f'\nzone "{domain}" {{\n type master;\n file "{path}";\n}};\n' fm.write_file(path, dns.apply_template(t['content'], domain, t)) elif z_type == "forward": ips = "".join([f" {x.strip()};" for x in request.form.getlist('forwarders[]') if x.strip()]) conf += f'\nzone "{domain}" {{\n type forward;\n forwarders {{{ips} }};\n}};\n' else: path = os.path.join(config.CACHE_PATH, f"db.{domain}") ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()]) conf += f'\nzone "{domain}" {{\n type slave;\n file "{path}";\n masters {{{ips} }};\n masterfile-format text;\n}};\n' fm.write_file(config.LOCAL_CONF, conf) s = fm.load_panel_settings() s['zone_owners'][domain] = session['username'] fm.save_panel_settings(s) subprocess.run(["rndc", "reconfig"]) if z_type == "master": cm.broadcast_to_cluster('add_slave', domain) flash(f"Zone '{domain}' created!", "success") return redirect(url_for('dashboard')) @app.route('/zones/manage/') @login_required def manage_zone(domain): clean = "." if domain == "root_zone" else domain.strip('/') if not check_zone_access(clean): return redirect(url_for('dashboard')) conf = fm.read_file(config.LOCAL_CONF) block = dns.get_zone_block(conf, clean) if not block: return redirect(url_for('dashboard')) path = dns.get_zone_file_path(clean) try: cmd = f"tail -n 5000 {config.QUERY_LOG_PATH} | grep -iE '({clean}|transfer|notify|axfr)' | tail -n 30" logs = subprocess.check_output(cmd, shell=True, text=True).strip().split('\n') except: logs = [] records = dns.parse_zone_file(path, clean) has_records = any(len(r)>0 for r in records.values()) m_ips = (re.search(r'masters\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ') f_ips = (re.search(r'forwarders\s*\{\s*(.*?)\s*\};', block, re.S | re.I) or type('m',(),{'group':lambda x,y:''})()).group(1).replace(';',' ') z_type = "hint" if "type hint" in block else "slave" if "type slave" in block else "forward" if "type forward" in block else "master" audit_logs = fm.get_audit_log(clean) backups = dns.get_backups(clean) dnssec_enabled = "dnssec-policy default" in block ds_records = dns.get_ds_records(clean) if dnssec_enabled else [] return render_template('manage_zone.html', domain=clean, type=z_type, records=records, has_records=has_records, path=path, logs=[l for l in logs if l], master_ips=m_ips, forwarders=f_ips, audit_logs=audit_logs, backups=backups, dnssec_enabled=dnssec_enabled, ds_records=ds_records) @app.route('/zones/delete/') @login_required def delete_zone(domain): clean = "." if domain == "root_zone" else domain.strip('/') if not check_zone_access(clean): return redirect(url_for('dashboard')) conf = fm.read_file(config.LOCAL_CONF) # FIX: Παίρνουμε το path ΠΡΙΝ σβήσουμε το block από το conf! path = dns.get_zone_file_path(clean) block = dns.get_zone_block(conf, clean) if block: fm.write_file(config.LOCAL_CONF, conf.replace(block, "")) s = fm.load_panel_settings() s['zone_owners'].pop(clean, None) fm.save_panel_settings(s) subprocess.run(["rndc", "reconfig"]) # Τώρα που έχουμε το σωστό path, το σβήνουμε με ασφάλεια. try: os.remove(path) except: pass if "type master" in block: cm.broadcast_to_cluster('delete_zone', clean) flash("Zone Deleted", "success") return redirect(url_for('dashboard')) # --- ZONE ACTIONS (RECORDS, IP, DNSSEC) --- @app.route('/zones/manage//add_record', methods=['POST']) @login_required def add_record(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) path, rtype = dns.get_zone_file_path(clean), request.form.get('rtype') name = dns.sanitize_record_name(request.form.get('name'), clean) val = dns.construct_record_value(request.form, rtype) new_content = dns.increment_serial(fm.read_file(path)) + f"{name}\tIN\t{rtype}\t{val}\n" is_valid, err = dns.check_zone_syntax(clean, new_content) if not is_valid: flash(f"Linter Error: {err}", "error") return redirect(url_for('manage_zone', domain=domain)) dns.backup_zone(clean, fm.read_file(path)) fm.write_file(path, new_content) subprocess.run(["rndc", "reload", clean]) fm.log_audit(session['username'], clean, f"Added record {rtype} {name}") flash("Record added", "success") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//delete_record', methods=['POST']) @login_required def delete_record(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) path, raw = dns.get_zone_file_path(clean), request.form.get('raw_line').strip() lines = fm.read_file(path).split('\n') for i, l in enumerate(lines): if l.strip() == raw: del lines[i] break new_content = dns.increment_serial('\n'.join(lines)) is_valid, err = dns.check_zone_syntax(clean, new_content) if not is_valid: flash(f"Linter Error: {err}", "error") return redirect(url_for('manage_zone', domain=domain)) dns.backup_zone(clean, fm.read_file(path)) fm.write_file(path, new_content) subprocess.run(["rndc", "reload", clean]) fm.log_audit(session['username'], clean, "Deleted a record") flash("Record deleted", "success") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//edit_record', methods=['POST']) @login_required def edit_record(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) path, old_raw, rtype = dns.get_zone_file_path(clean), request.form.get('old_raw').strip(), request.form.get('rtype') name = dns.sanitize_record_name(request.form.get('name'), clean) val = dns.construct_record_value(request.form, rtype) lines = fm.read_file(path).split('\n') for i, l in enumerate(lines): if l.strip() == old_raw: lines[i] = f"{name}\tIN\t{rtype}\t{val}" break new_content = dns.increment_serial('\n'.join(lines)) is_valid, err = dns.check_zone_syntax(clean, new_content) if not is_valid: flash(f"Linter Error: {err}", "error") return redirect(url_for('manage_zone', domain=domain)) dns.backup_zone(clean, fm.read_file(path)) fm.write_file(path, new_content) subprocess.run(["rndc", "reload", clean]) fm.log_audit(session['username'], clean, f"Edited record {rtype} {name}") flash("Record updated", "success") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//rollback/', methods=['POST']) @login_required def rollback_zone(domain, ts): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) backup_file = os.path.join(config.BACKUP_DIR, f"{clean}_{ts}.db") if os.path.exists(backup_file): path = dns.get_zone_file_path(clean) dns.backup_zone(clean, fm.read_file(path)) fm.write_file(path, fm.read_file(backup_file)) subprocess.run(["rndc", "reload", clean]) fm.log_audit(session['username'], clean, f"Rolled back to {ts}") flash("Zone rolled back", "success") else: flash("Backup not found", "error") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//dnssec', methods=['POST']) @login_required def toggle_dnssec(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) conf = fm.read_file(config.LOCAL_CONF) block = dns.get_zone_block(conf, clean) if not block: flash("Zone configuration block not found!", "error") return redirect(url_for('manage_zone', domain=domain)) if "dnssec-policy" in block: new_block = re.sub(r'\s*dnssec-policy default;\s*inline-signing yes;', '', block) fm.log_audit(session['username'], clean, "Disabled DNSSEC") else: new_block = block.replace('type master;', 'type master;\n dnssec-policy default;\n inline-signing yes;') subprocess.run(["chown", "-R", "bind:bind", config.BIND_PATH]) fm.log_audit(session['username'], clean, "Enabled DNSSEC") fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block)) subprocess.run(["rndc", "reconfig"]) subprocess.run(["rndc", "reload", clean]) return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//edit_ips', methods=['POST']) @login_required def edit_zone_ips(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean) ips = "".join([f" {x.strip()};" for x in request.form.getlist('ips[]') if x.strip()]) or " none;" if "type slave" in block: new_block = re.sub(r'masters\s*\{.*?\};', f'masters {{{ips} }};', block, flags=re.S|re.I) elif "type forward" in block: new_block = re.sub(r'forwarders\s*\{.*?\};', f'forwarders {{{ips} }};', block, flags=re.S|re.I) fm.write_file(config.LOCAL_CONF, conf.replace(block, new_block)) subprocess.run(["rndc", "reconfig"]) return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/assign', methods=['POST']) @admin_required def assign_zone(): d, o = request.form.get('domain'), request.form.get('owner') s = fm.load_panel_settings() s['zone_owners'][d] = o fm.save_panel_settings(s) flash("Assigned", "success") return redirect(url_for('dashboard')) @app.route('/zones/manage//cluster_sync', methods=['POST']) @login_required def cluster_sync_zone(domain): clean = "." if domain == "root_zone" else domain.strip('/') if not check_zone_access(clean): return redirect(url_for('dashboard')) conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean) if block and "type master" in block: cm.broadcast_to_cluster('add_slave', clean) time.sleep(1.5) subprocess.run(["rndc", "notify", clean]) flash(f"Cluster Sync triggered for {clean}!", "success") else: flash("Only Master zones can be synced.", "error") return redirect(url_for('dashboard')) @app.route('/zones/manage//sync') @login_required def sync_zone(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) try: subprocess.run(["rndc", "retransfer", clean], check=True) flash(f"Sync initiated", "success") except: flash("Sync failed", "error") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//convert_to_master') @login_required def convert_to_master(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean) if not block or "type slave" not in block: return redirect(url_for('dashboard')) path_old = dns.get_zone_file_path(clean) res = subprocess.run(["named-compilezone", "-f", "raw", "-F", "text", "-o", "-", clean, path_old], capture_output=True, text=True) records_text = res.stdout if res.returncode == 0 else fm.read_file(path_old) path_new = os.path.join(config.BIND_PATH, f"db.{clean}" if clean != "." else "db.root") fm.write_file(config.LOCAL_CONF, conf.replace(block, f'zone "{clean}" {{\n type master;\n file "{path_new}";\n}};')) fm.write_file(path_new, records_text) subprocess.run(["rndc", "reconfig"]) fm.log_audit(session['username'], clean, "Converted to Master") return redirect(url_for('manage_zone', domain=domain)) @app.route('/zones/manage//convert_to_slave', methods=['POST']) @login_required def convert_to_slave(domain): clean = "." if domain == "root_zone" else domain if not check_zone_access(clean): return redirect(url_for('dashboard')) conf, block = fm.read_file(config.LOCAL_CONF), dns.get_zone_block(fm.read_file(config.LOCAL_CONF), clean) if not block or "type master" not in block: return redirect(url_for('dashboard')) path_old = dns.get_zone_file_path(clean) path_new = os.path.join(config.CACHE_PATH, f"db.{clean}" if clean != "." else "db.root") ips = "".join([f" {x.strip()};" for x in request.form.getlist('master_ips[]') if x.strip()]) 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}};')) try: os.remove(path_old) except: pass subprocess.run(["rndc", "reconfig"]) fm.log_audit(session['username'], clean, "Converted to Slave") return redirect(url_for('manage_zone', domain=domain)) # --- ADBLOCK & ANALYTICS VIEWS --- @app.route('/adanalytics') @admin_required def adanalytics_page(): s = fm.load_panel_settings() return render_template('adanalytics.html', log_enabled=(s.get('query_log_enabled') == 'yes')) @app.route('/adblock') @admin_required def adblock_dashboard(): settings = fm.load_panel_settings() adblock_lists = fm.load_adblock_lists() custom_rules = fm.load_custom_rules() rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF) and 'response-policy' in fm.read_file(config.OPTIONS_FILE) return render_template('adblock.html', settings=settings, adblock_lists=adblock_lists, custom_rules=custom_rules, rpz_enabled=rpz_enabled) @app.route('/analytics') @admin_required def analytics(): return render_template('analytics.html') # --- CLUSTER & SETTINGS VIEWS --- @app.route('/cluster', methods=['GET', 'POST']) @admin_required def cluster_management(): settings = fm.load_panel_settings() if request.method == 'POST': action = request.form.get('action') if action == 'save_ip': settings['server_public_ip'] = request.form.get('server_public_ip', '').strip() flash("Master IP Configuration Saved", "success") elif action == 'add_node': node_url = request.form.get('node_url', '').strip() if node_url and not node_url.startswith('http'): node_url = 'http://' + node_url node_url = node_url.rstrip('/') new_node = {"name": request.form.get('node_name', '').strip(), "url": node_url, "api_key": request.form.get('node_api_key', '').strip()} if new_node['name'] and new_node['url'] and new_node['api_key']: settings['cluster_nodes'] = [n for n in settings.get('cluster_nodes', []) if n['name'].strip() != new_node['name']] settings.setdefault('cluster_nodes', []).append(new_node) flash("Cluster Node Added Successfully!", "success") elif action == 'delete_node': try: node_idx = int(request.form.get('node_index')) settings['cluster_nodes'].pop(node_idx) flash("Cluster Node Removed", "success") except: flash("Failed to remove node", "error") elif action == 'regenerate_api': settings['api_key'] = secrets.token_hex(32) flash("API Key Regenerated. Update your nodes!", "success") elif action == 'save_master': m_url = request.form.get('master_url', '').strip() if m_url and not m_url.startswith('http'): m_url = 'http://' + m_url m_url = m_url.rstrip('/') settings['master_node'] = {"name": request.form.get('master_name', '').strip(), "url": m_url} flash("Master Node Linked", "success") elif action == 'unlink_master': settings['master_node'] = {} flash("Master Node Unlinked", "success") fm.save_panel_settings(settings) return redirect(url_for('cluster_management')) return render_template('cluster.html', settings=settings) @app.route('/settings') @admin_required def settings(): conf = fm.read_file(config.OPTIONS_FILE) def parse_opt(regex, default='any'): m = re.search(regex, conf, re.DOTALL | re.I) return [x.strip() for x in m.group(1).split(';') if x.strip()] if m else [default] b_opt = { 'listen_v4': parse_opt(r'listen-on\s*port\s+53\s*\{\s*(.*?)\s*\};'), 'allow_query': parse_opt(r'allow-query\s*\{\s*(.*?)\s*\};'), 'allow_transfer': parse_opt(r'allow-transfer\s*\{\s*(.*?)\s*\};', 'none'), 'also_notify': parse_opt(r'also-notify\s*\{\s*(.*?)\s*\};', ''), 'dnssec': (re.search(r'dnssec-validation\s+(.*?);', conf) or type('m',(),{'group':lambda x,y:'auto'})()).group(1) } return render_template('general_config.html', b_opt=b_opt, p_data=fm.load_panel_settings()) @app.route('/settings/save', methods=['POST']) @admin_required def save_settings(): def join_ips(field): vals = [x.strip() for x in request.form.getlist(field) if x.strip()] return "".join([f" {x};" for x in vals]) if vals else "" lv4 = join_ips('listen_v4[]') or " any;" aq = join_ips('allow_query[]') or " any;" at = join_ips('allow_transfer[]') or " none;" an_raw = join_ips('also_notify[]') an_line = f" also-notify {{{an_raw} }};\n" if an_raw else "" dnssec = request.form.get('dnssec', 'auto') query_log = request.form.get('query_log_enabled', 'no') log_block = "" if query_log == 'yes': subprocess.run(["mkdir", "-p", "/var/log/named"]) subprocess.run(["touch", "/var/log/named/queries.log"]) subprocess.run(["chown", "-R", "bind:bind", "/var/log/named"]) log_block = """ logging { channel queries_log { file "/var/log/named/queries.log" versions 3 size 5m; severity info; print-time yes; print-category yes; print-severity yes; }; category queries { queries_log; }; category xfer-in { queries_log; }; category xfer-out { queries_log; }; category notify { queries_log; }; category rpz { queries_log; }; };""" old_opts = fm.read_file(config.OPTIONS_FILE) rpz_line = ' response-policy { zone "rpz.blacklist"; };\n' if 'response-policy' in old_opts else '' 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}" fm.write_file(config.OPTIONS_FILE, opt_content.strip()) 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[]') new_templates = {} for idx, name in enumerate(t_names): name = name.strip() if not name: continue keys, vals = request.form.getlist(f'custom_key_{idx}[]'), request.form.getlist(f'custom_val_{idx}[]') custom_vars = {keys[i].strip().upper(): vals[i] for i in range(len(keys)) if keys[i].strip()} 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} s = fm.load_panel_settings() s.update({"templates": new_templates, "stats_retention": int(request.form.get('stats_retention', 30)), "query_log_enabled": query_log}) fm.save_panel_settings(s) cm.broadcast_templates_to_cluster(new_templates) subprocess.run(["systemctl", "restart", config.BIND_SERVICE]) flash("Settings Saved", "success") return redirect(url_for('settings')) @app.route('/settings/view_mode', methods=['POST']) @login_required def update_view_mode(): mode = request.json.get('mode', 'cards') users = fm.load_users() if session['username'] in users: users[session['username']]['view_mode'] = mode fm.save_users(users) return jsonify({"status": "success"}) # --- USERS VIEWS --- @app.route('/users') @admin_required def manage_users(): return render_template('users.html', users=fm.load_users(), all_templates=list(fm.load_panel_settings()['templates'].keys())) @app.route('/users/save', methods=['POST']) @admin_required def save_user(): 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') assigned = request.form.getlist('assigned_templates[]') users = fm.load_users() if action == 'add': if u in users: flash("User exists", "error") else: users[u] = {"password": generate_password_hash(p), "role": r, "assigned_templates": assigned, "view_mode": "cards"} cm.broadcast_user_to_cluster('save', u, users[u]) elif action == 'edit' and old_u in users: pw = generate_password_hash(p) if p else users[old_u]['password'] 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')} if u != old_u: users[u] = users.pop(old_u) cm.broadcast_user_to_cluster('save', u, users[u], old_username=old_u if u != old_u else None) fm.save_users(users) flash("User saved", "success") return redirect(url_for('manage_users')) @app.route('/users/delete/') @admin_required def delete_user(username): users = fm.load_users() if username != 'admin' and username in users: del users[username] fm.save_users(users) cm.broadcast_user_to_cluster('delete', username) flash("User deleted", "success") return redirect(url_for('manage_users')) # --- ADBLOCK API (MASTER) --- @app.route('/api/adblock/update', methods=['POST']) @admin_required def route_update_adblock(): result = ab.generate_and_apply_rpz() if result.get("status") == "success": cm.broadcast_adblock_to_cluster('enable') return jsonify(result) else: return jsonify(result), 500 @app.route('/api/adblock/disable', methods=['POST']) @admin_required def route_disable_adblock(): result = ab.disable_rpz() cm.broadcast_adblock_to_cluster('disable') return jsonify(result) @app.route('/api/adblock/sync_cluster', methods=['POST']) @admin_required def route_sync_adblock_cluster(): rpz_enabled = 'zone "rpz.blacklist"' in fm.read_file(config.LOCAL_CONF) action = 'enable' if rpz_enabled else 'disable' settings = fm.load_panel_settings() nodes = settings.get('cluster_nodes', []) lists = fm.load_adblock_lists() rules = fm.load_custom_rules() count = settings.get('adblock_domains_count', 0) update_time = settings.get('adblock_last_update', 'Never') success_count = 0 for node in nodes: try: url = f"{node['url'].rstrip('/')}/api/v1/cluster/adblock" headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"} payload = {"action": action, "lists": lists, "rules": rules, "count": count, "time": update_time} proxy_handler = urllib.request.ProxyHandler({}) opener = urllib.request.build_opener(proxy_handler) req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST') with opener.open(req, timeout=10) as response: if response.status == 200: success_count += 1 except Exception as e: print(f"Cluster AdBlock Sync FAIL: {node['name']} -> {e}") return jsonify({"status": "success", "message": f"Sent AdBlock config to {success_count}/{len(nodes)} cluster nodes."}) @app.route('/api/v1/cluster/templates', methods=['POST']) @api_required def api_cluster_save_templates(): new_templates = request.get_json(force=True, silent=True) if new_templates is None: return jsonify({"status": "error", "message": "Invalid templates data"}), 400 s = fm.load_panel_settings() s['templates'] = new_templates fm.save_panel_settings(s) return jsonify({"status": "success", "message": "Templates synchronized"}) @app.route('/api/adblock/toggle_list', methods=['POST']) @admin_required def toggle_adblock_list(): list_id = request.json.get('id') state = request.json.get('state') lists = fm.load_adblock_lists() for l in lists: if l['id'] == list_id: l['enabled'] = state break fm.save_adblock_lists(lists) return jsonify({"status": "success"}) @app.route('/api/adblock/add_custom', methods=['POST']) @admin_required def add_custom_adblock(): name = request.form.get('name', '').strip() url = request.form.get('url', '').strip() desc = request.form.get('desc', 'Custom User Blocklist').strip() if not name or not url: return redirect(url_for('adblock_dashboard')) lists = fm.load_adblock_lists() new_id = "custom_" + str(int(time.time())) lists.append({"id": new_id, "name": name, "desc": desc, "url": url, "enabled": True, "category": "Custom", "is_custom": True, "info": url}) fm.save_adblock_lists(lists) flash("Custom list added", "success") return redirect(url_for('adblock_dashboard')) @app.route('/api/adblock/edit_custom', methods=['POST']) @admin_required def edit_custom_adblock(): list_id = request.form.get('list_id') name = request.form.get('name', '').strip() url = request.form.get('url', '').strip() desc = request.form.get('desc', '').strip() lists = fm.load_adblock_lists() for l in lists: if l['id'] == list_id and l.get('is_custom'): l['name'], l['desc'], l['url'], l['info'] = name, desc, url, url break fm.save_adblock_lists(lists) flash("Custom list updated", "success") return redirect(url_for('adblock_dashboard')) @app.route('/api/adblock/delete_list/', methods=['GET']) @admin_required def delete_adblock_list(list_id): lists = [l for l in fm.load_adblock_lists() if l['id'] != list_id or not l['is_custom']] fm.save_adblock_lists(lists) flash("List deleted", "success") return redirect(url_for('adblock_dashboard')) @app.route('/api/adblock/rules/add', methods=['POST']) @admin_required def add_adblock_rule(): rtype, domain = request.form.get('type'), request.form.get('domain', '').strip().lower() domain = re.sub(r'^https?://', '', domain).rstrip('/') rules = fm.load_custom_rules() if domain not in rules[rtype]: rules[rtype].append(domain) fm.save_custom_rules(rules) return jsonify({"status": "success"}) @app.route('/api/adblock/rules/edit', methods=['POST']) @admin_required def edit_adblock_rule(): rtype = request.form.get('type') old_domain = request.form.get('old_domain', '').strip() new_domain = re.sub(r'^https?://', '', request.form.get('new_domain', '').strip().lower()).rstrip('/') rules = fm.load_custom_rules() if old_domain in rules.get(rtype, []): rules[rtype][rules[rtype].index(old_domain)] = new_domain fm.save_custom_rules(rules) return jsonify({"status": "success"}) @app.route('/api/adblock/rules/delete', methods=['POST']) @admin_required def delete_adblock_rule(): rtype, domain = request.form.get('type'), request.form.get('domain', '').strip() rules = fm.load_custom_rules() rules[rtype] = [d for d in rules.get(rtype, []) if d != domain] fm.save_custom_rules(rules) return jsonify({"status": "success"}) # --- STATS API --- @app.route('/api/adblock/stats_live') @admin_required def api_adblock_stats_live(): with stats.stats_lock: try: with open(config.STATS_FILE, "r") as f: st = json.load(f) except: st = {} now = datetime.datetime.now().strftime("%Y-%m-%d") day_data = st.get(now, {}) total_q = day_data.get("queries", 0) total_b = day_data.get("blocked_queries", 0) top_domains = sorted(day_data.get("blocked_domains", {}).items(), key=lambda x: x[1], reverse=True)[:10] top_clients = sorted(day_data.get("blocked_clients", {}).items(), key=lambda x: x[1], reverse=True)[:10] percentage = round((total_b / total_q * 100), 2) if total_q > 0 else 0 return jsonify({ "total_queries": total_q, "total_blocked": total_b, "percentage": percentage, "top_domains": top_domains, "top_clients": top_clients, "timeline": day_data.get("timeline", {}), "block_timeline": day_data.get("block_timeline", {}) }) @app.route('/api/stats') @admin_required def api_stats(): with stats.stats_lock: return fm.read_file(config.STATS_FILE) or "{}" @app.route('/api/ip_queries/') @admin_required def get_ip_queries(ip): if not re.match(r'^[\d\.\:a-fA-F]+$', ip): return jsonify({"error": "Invalid IP format"}) try: cmd = f"grep -i '{ip}#' {config.QUERY_LOG_PATH} | tail -n 50" proc = subprocess.run(cmd, shell=True, capture_output=True, text=True) lines = proc.stdout.strip().split('\n') queries = [] for line in reversed(lines): if not line: continue m = re.search(r'query:\s+([^\s]+)\s+IN\s+([A-Z0-9]+)', line, re.I) if m: time_str = " ".join(line.split()[:2]) if len(line.split()) > 2 else "" queries.append({"time": time_str, "domain": m.group(1), "type": m.group(2)}) return jsonify({"ip": ip, "queries": queries}) except Exception as e: return jsonify({"error": str(e)}) # --- UTILS API --- @app.route('/api/zones/test_dig', methods=['POST']) @login_required def test_dig_transfer(): domain, target_ip = request.json.get('domain'), request.json.get('target_ip', '127.0.0.1') if not domain: return jsonify({"error": "No domain provided"}), 400 block = dns.get_zone_block(fm.read_file(config.LOCAL_CONF), domain) if block: m = re.search(r'masters\s*\{\s*([^;]+);', block) if m: target_ip = m.group(1).strip() try: output = subprocess.run(["dig", f"@{target_ip}", domain, "AXFR"], capture_output=True, text=True, timeout=8).stdout if "Transfer failed" in output or "connection refused" in output.lower() or not output.strip(): return jsonify({"success": False, "raw": output, "message": f"Transfer failed from Master ({target_ip})"}) analysis, total_count = {}, 0 for line in output.split('\n'): if not line or line.startswith((';', '\n')): continue for p in line.split(): if p in ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'SRV', 'PTR']: analysis[p] = analysis.get(p, 0) + 1 total_count += 1 break return jsonify({"success": True, "total": total_count, "analysis": analysis, "raw": output, "target": target_ip}) except Exception as e: return jsonify({"success": False, "message": str(e)}) @app.route('/api/service/status') @admin_required def get_service_status(): try: res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True) is_active = res.stdout.strip() == "active" uptime = "Unknown" if is_active: res_up = subprocess.run(["systemctl", "show", config.BIND_SERVICE, "--property=ActiveEnterTimestamp"], capture_output=True, text=True) uptime = res_up.stdout.replace("ActiveEnterTimestamp=", "").strip() return jsonify({"status": "active" if is_active else "inactive", "uptime": uptime}) except: return jsonify({"status": "error", "uptime": "N/A"}) @app.route('/service/') @admin_required def service_control(action): try: if action == 'reload': subprocess.run(["rndc", "reload"], check=True) else: subprocess.run(["systemctl", action, config.BIND_SERVICE], check=True) except: pass return redirect(request.referrer or url_for('dashboard')) @app.route('/backup/export') @admin_required def export_backup(): memory_file = io.BytesIO() zip_f = zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) core_files = [ config.LOCAL_CONF, config.OPTIONS_FILE, config.PANEL_SETTINGS, config.USERS_FILE, config.STATS_FILE, config.ADBLOCK_LISTS_FILE, config.ADBLOCK_CUSTOM_RULES_FILE ] for f in core_files: if os.path.exists(f): zip_f.write(f, os.path.basename(f)) for f in os.listdir(config.BIND_PATH): if f.startswith("db.") and f != "db.rpz.blacklist": full_path = os.path.join(config.BIND_PATH, f) if os.path.exists(full_path): zip_f.write(full_path, f) zip_f.close() memory_file.seek(0) return send_file(memory_file, download_name=f"bind_backup_{time.strftime('%Y%m%d_%H%M%S')}.zip", as_attachment=True) @app.route('/backup/import', methods=['POST']) @admin_required def import_backup(): file = request.files.get('backup_file') if file and file.filename.endswith('.zip'): with zipfile.ZipFile(file, 'r') as zf: zf.extractall(config.BIND_PATH) subprocess.run(["systemctl", "restart", config.BIND_SERVICE]) flash("Restored", "success") return redirect(url_for('dashboard')) # --- CLUSTER SLAVE RECEIVERS (V1 API) --- @app.route('/api/v1/cluster/adblock', methods=['POST']) @api_required def api_cluster_adblock(): data = request.json action = data.get('action') master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip() if 'lists' in data: fm.save_adblock_lists(data['lists']) if 'rules' in data: fm.save_custom_rules(data['rules']) s = fm.load_panel_settings() if 'count' in data: s['adblock_domains_count'] = data['count'] if 'time' in data: s['adblock_last_update'] = data['time'] fm.save_panel_settings(s) conf, opts = fm.read_file(config.LOCAL_CONF), fm.read_file(config.OPTIONS_FILE) if action == 'enable': if 'zone "rpz.blacklist"' not in conf: path = os.path.join(config.CACHE_PATH, "db.rpz.blacklist") conf += f'\nzone "rpz.blacklist" {{\n type slave;\n file "{path}";\n masters {{ {master_ip}; }};\n allow-query {{ any; }};\n}};\n' fm.write_file(config.LOCAL_CONF, conf) if "response-policy" not in opts: opts = re.sub(r'(options\s*\{)', r'\1\n response-policy { zone "rpz.blacklist"; };', opts) fm.write_file(config.OPTIONS_FILE, opts) else: block = dns.get_zone_block(conf, "rpz.blacklist") if block: fm.write_file(config.LOCAL_CONF, conf.replace(block, "")) opts = re.sub(r'\s*response-policy\s*\{\s*zone\s*"rpz\.blacklist";\s*\};\s*', '\n', opts) fm.write_file(config.OPTIONS_FILE, opts) subprocess.run(["systemctl", "restart", config.BIND_SERVICE]) return jsonify({"status": "success", "message": "RPZ synced on slave."}) @app.route('/api/v1/cluster/zone', methods=['POST']) @api_required def api_add_slave(): data = request.get_json(force=True, silent=True) or {} domain = data.get('domain') if not domain: return jsonify({"status": "error", "message": "No domain provided"}), 400 domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', domain).replace('../', '') masters_list = data.get('masters', []) if not masters_list: master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip() masters_list = [master_ip] masters_string = "".join([f" {ip};" for ip in masters_list]) conf = fm.read_file(config.LOCAL_CONF) old_block = dns.get_zone_block(conf, domain) if old_block: conf = conf.replace(old_block, "") message = f"Zone {domain} overwritten" else: message = f"Zone {domain} added" path = os.path.join(config.CACHE_PATH, f"db.{domain}") new_zone = f'\nzone "{domain}" {{\n type slave;\n file "{path}";\n masters {{{masters_string} }};\n masterfile-format text;\n}};\n' fm.write_file(config.LOCAL_CONF, (conf.strip() + "\n" + new_zone).strip() + "\n") subprocess.run(["rndc", "reconfig"]) return jsonify({"status": "success", "message": message}) @app.route('/api/v1/cluster/zone/', methods=['DELETE']) @api_required def api_delete_zone(domain): clean = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', domain).replace('../', '') conf = fm.read_file(config.LOCAL_CONF) block = dns.get_zone_block(conf, clean) if not block: return jsonify({"status": "error", "message": "Zone not found"}), 404 path = dns.get_zone_file_path(clean) fm.write_file(config.LOCAL_CONF, conf.replace(block, "")) subprocess.run(["rndc", "reconfig"]) try: os.remove(path) except: pass fm.log_audit("API_SYNC", clean, "Auto-deleted via Cluster Sync") return jsonify({"status": "success", "message": f"Zone {clean} deleted."}) @app.route('/api/v1/cluster/ping', methods=['GET']) @api_required def api_ping(): return jsonify({"status": "success", "message": "Node is synchronized"}) @app.route('/cluster/check_node', methods=['POST']) @login_required def check_cluster_node(): node_name = request.json.get('node_name') node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == node_name), None) if not node: return jsonify({"status": "error"}) try: url = f"{node['url'].rstrip('/')}/api/v1/cluster/ping" proxy_handler = urllib.request.ProxyHandler({}) opener = urllib.request.build_opener(proxy_handler) req = urllib.request.Request(url, headers={"X-API-Key": node['api_key']}) with opener.open(req, timeout=10) as response: if response.status == 200: return jsonify({"status": "online"}) except urllib.error.HTTPError as e: print(f"\n[CLUSTER HTTP ERROR] Node: {node_name} -> Κωδικός: {e.code}\n") if e.code == 401: return jsonify({"status": "auth_error"}) except Exception as e: print(f"\n[CLUSTER NETWORK ERROR] Node: {node_name} -> Αιτία: {str(e)}\n") return jsonify({"status": "offline"}) @app.route('/api/v1/cluster/user', methods=['POST']) @api_required def api_cluster_save_user(): data = request.get_json(force=True, silent=True) or {} username, user_data, old_username = data.get('username'), data.get('user_data'), data.get('old_username') if not username or not user_data: return jsonify({"status": "error", "message": "Missing user data"}), 400 users = fm.load_users() if old_username and old_username in users and old_username != username: del users[old_username] users[username] = user_data fm.save_users(users) return jsonify({"status": "success", "message": f"User {username} synced"}) @app.route('/api/v1/cluster/user/', methods=['DELETE']) @api_required def api_cluster_delete_user(username): users = fm.load_users() if username in users and username != 'admin': del users[username] fm.save_users(users) return jsonify({"status": "success", "message": f"User {username} deleted"}) # --- REMOTE SERVICE CONTROL (SLAVE RECEIVER) --- @app.route('/api/v1/cluster/service', methods=['POST']) @api_required def api_cluster_service(): data = request.get_json(force=True, silent=True) or {} action = data.get('action') if action in ['start', 'stop', 'restart', 'reload']: try: res = subprocess.run(["systemctl", action, config.BIND_SERVICE], capture_output=True, text=True) if res.returncode == 0: return jsonify({"status": "success", "message": f"Service {action}ed successfully"}) else: return jsonify({"status": "error", "message": f"Systemctl failed: {res.stderr.strip()}"}) except Exception as e: return jsonify({"status": "error", "message": str(e)}) elif action == 'status': try: res = subprocess.run(["systemctl", "is-active", config.BIND_SERVICE], capture_output=True, text=True) is_active = res.stdout.strip() == "active" return jsonify({"status": "success", "service_status": "active" if is_active else "inactive"}) except: return jsonify({"status": "error", "service_status": "unknown"}) return jsonify({"status": "error", "message": "Invalid action provided"}) # --- REMOTE SERVICE PROXY (MASTER TO SLAVE) --- @app.route('/cluster/node_service', methods=['POST']) @login_required def cluster_node_service(): import urllib.request, urllib.error try: data = request.get_json(force=True, silent=True) or {} node = next((n for n in fm.load_panel_settings().get('cluster_nodes', []) if n['name'] == data.get('node_name')), None) if not node: return jsonify({"status": "error", "message": "Node not found."}) url = f"{node['url'].rstrip('/')}/api/v1/cluster/service" 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') proxy_handler = urllib.request.ProxyHandler({}) https_handler = urllib.request.HTTPSHandler(context=config.ssl_ctx) opener = urllib.request.build_opener(proxy_handler, https_handler) with opener.open(req, timeout=15) as response: return jsonify(json.loads(response.read().decode('utf-8'))) except urllib.error.HTTPError as e: return jsonify({"status": "error", "message": f"Slave HTTP Error {e.code}."}) except Exception as e: return jsonify({"status": "error", "message": f"Connection failed: {str(e)}"}) # --- GLOBAL CLUSTER SYNC (MASTER ACTION) --- @app.route('/api/cluster/sync_all', methods=['POST']) @admin_required def sync_all_cluster(): conf = fm.read_file(config.LOCAL_CONF) import re zones = re.findall(r'zone\s+"([^"]+)"', conf) ignore = ["rpz.blacklist", ".", "localhost", "127.in-addr.arpa", "0.in-addr.arpa", "255.in-addr.arpa"] valid_zones = [z for z in zones if z not in ignore] def bulk_sync(): import urllib.request, json, ssl, time settings = fm.load_panel_settings() nodes = settings.get('cluster_nodes', []) users = fm.load_users() ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE for node in nodes: # 1. Sync Users First for u_name, u_data in users.items(): try: url = f"{node['url'].rstrip('/')}/api/v1/cluster/user" payload = {"username": u_name, "user_data": u_data} 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') urllib.request.urlopen(req, timeout=5, context=ctx) except Exception as e: print(f"Cluster Sync FAIL: {node['name']} -> User {u_name} ({e})") # 2. Sync Zones for z in valid_zones: masters = dns.get_zone_masters_list(z) if not masters: master_ip = settings.get('server_public_ip', '').strip() if master_ip: masters = [master_ip] try: url = f"{node['url'].rstrip('/')}/api/v1/cluster/zone" headers = {"X-API-Key": node['api_key'], "Content-Type": "application/json"} payload = {"domain": z, "masters": masters} req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST') urllib.request.urlopen(req, timeout=5, context=ctx) print(f"Cluster Sync OK: {node['name']} -> {z}") except Exception as e: print(f"Cluster Sync FAIL: {node['name']} -> {z} ({e})") time.sleep(0.5) import threading threading.Thread(target=bulk_sync, daemon=True).start() return jsonify({"status": "success", "message": f"Global sync started! Sending {len(fm.load_users())} users and {len(valid_zones)} zones in the background."}) # --- USER API & TOKEN ROUTES --- @app.route('/settings/generate_token', methods=['POST']) @login_required def generate_user_token(): users = fm.load_users() new_token = secrets.token_urlsafe(32) users[session['username']]['api_token'] = new_token fm.save_users(users) cm.broadcast_user_to_cluster('save', session['username'], users[session['username']]) flash("New API Token generated successfully!", "success") return redirect(url_for('profile')) @app.route('/api/v1/user/zone/check/', methods=['GET']) @user_api_required def api_user_check_zone(domain): conf = fm.read_file(config.LOCAL_CONF) exists = f'zone "{domain}"' in conf return jsonify({"status": "success", "exists": exists, "domain": domain}) @app.route('/api/v1/user/zone/create', methods=['POST']) @user_api_required def api_user_create_zone(): data = request.get_json(force=True, silent=True) or {} domain = data.get('domain', '').strip() t_name = data.get('template_name') z_type = data.get('type', 'master') if not domain: return jsonify({"status": "error", "message": "Domain is required"}), 400 conf = fm.read_file(config.LOCAL_CONF) if f'zone "{domain}"' in conf: return jsonify({"status": "error", "message": "Zone already exists"}), 409 if z_type == 'master': if not t_name: return jsonify({"status": "error", "message": "template_name is mandatory for Master zones"}), 400 if session['role'] != 'admin': assigned = fm.load_users().get(session['username'], {}).get('assigned_templates', []) if t_name not in assigned: return jsonify({"status": "error", "message": "Template access denied"}), 403 t = fm.load_panel_settings()['templates'].get(t_name) if not t: return jsonify({"status": "error", "message": "Template not found"}), 404 path = os.path.join(config.BIND_PATH, f"db.{domain}") new_conf = conf + f'\nzone "{domain}" {{\n type master;\n file "{path}";\n}};\n' fm.write_file(path, dns.apply_template(t['content'], domain, t)) else: return jsonify({"status": "error", "message": "Only Master zones are supported via this API endpoint currently."}), 400 fm.write_file(config.LOCAL_CONF, new_conf) s = fm.load_panel_settings() s['zone_owners'][domain] = session['username'] fm.save_panel_settings(s) subprocess.run(["rndc", "reconfig"]) if data.get('sync', True): cm.broadcast_to_cluster('add_slave', domain) return jsonify({"status": "success", "message": f"Zone {domain} created"}) @app.route('/api/v1/user/record/add', methods=['POST']) @user_api_required def api_user_add_record(): data = request.get_json(force=True, silent=True) or {} domain = data.get('domain') rtype = data.get('type', 'A').upper() name = data.get('name') value = data.get('value') if not check_zone_access(domain): return jsonify({"status": "error", "message": "Access denied to this zone"}), 403 path = dns.get_zone_file_path(domain) content = fm.read_file(path) clean_name = dns.sanitize_record_name(name, domain) val = dns.construct_record_value({'value': value}, rtype) if f"{clean_name}\tIN\t{rtype}\t{val}" in content: return jsonify({"status": "error", "message": "Record already exists"}), 409 new_content = dns.increment_serial(content) + f"{clean_name}\tIN\t{rtype}\t{val}\n" is_valid, err = dns.check_zone_syntax(domain, new_content) if not is_valid: return jsonify({"status": "error", "message": f"Invalid syntax: {err}"}), 400 fm.write_file(path, new_content) subprocess.run(["rndc", "reload", domain]) if data.get('sync', True): cm.broadcast_to_cluster('add_slave', domain) return jsonify({"status": "success", "message": "Record added"}) @app.route('/api/v1/user/sync', methods=['POST']) @user_api_required def api_user_sync_cluster(): data = request.get_json(force=True, silent=True) or {} domain = data.get('domain') if not check_zone_access(domain): return jsonify({"status": "error", "message": "Access denied"}), 403 cm.broadcast_to_cluster('add_slave', domain) return jsonify({"status": "success", "message": "Sync command sent"}) @app.route('/api/v1/user/templates', methods=['GET']) @user_api_required def api_user_get_templates(): users = fm.load_users() assigned = users.get(session['username'], {}).get('assigned_templates', []) all_templates = fm.load_panel_settings().get('templates', {}) if session['role'] == 'admin': user_templates = list(all_templates.keys()) else: user_templates = [t for t in assigned if t in all_templates] return jsonify({"status": "success", "templates": user_templates}) if __name__ == '__main__': # Είναι σημαντικό το threaded=True για να μην κάνει "block" το UI στο self-ping app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)