| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059 |
- 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
- 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()
- # --- 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
- 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'))
- # --- 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()
- owners, users = settings.get('zone_owners', {}), fm.load_users()
-
- if session['role'] == 'admin':
- zones = zones_raw
- else:
- zones = [z for z in zones_raw if owners.get(z[0]) == session['username']]
-
- 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=settings['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}")
- conf += f'\nzone "{domain}" {{\n type master;\n file "{path}";\n}};\n'
- t_name = request.form.get('template_name')
- if t_name:
- t = fm.load_panel_settings()['templates'].get(t_name)
- if t: fm.write_file(path, dns.apply_template(t['content'], domain, t))
- else:
- s_ip = fm.load_panel_settings().get('server_public_ip', '') or '127.0.0.1'
- fm.write_file(path, f"$TTL 86400\n@ IN SOA ns1.{domain}. admin.{domain}. (\n {time.strftime('%Y%m%d01')}\n 3600\n 1800\n 604800\n 86400\n)\n@ IN NS ns1.{domain}.\nns1 IN A {s_ip}\n")
-
- 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/<path:domain>')
- @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/<path:domain>')
- @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)
- 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"])
- 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/<path:domain>/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/<path:domain>/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/<path:domain>/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/<path:domain>/rollback/<ts>', 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/<path:domain>/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)
-
- # BUG FIX: Αν δεν βρει τη ζώνη, να μην κρασάρει, αλλά να γυρίσει πίσω
- 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/<path:domain>/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/<path:domain>/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/<path:domain>/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/<path:domain>/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/<path:domain>/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)
- 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"}
- 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')}
- if u != old_u: users[u] = users.pop(old_u)
- fm.save_users(users)
- flash("User saved", "success")
- return redirect(url_for('manage_users'))
- @app.route('/users/delete/<username>')
- @admin_required
- def delete_user(username):
- users = fm.load_users()
- if username != 'admin' and username in users:
- del users[username]
- fm.save_users(users)
- 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":
- # BUG FIX: Ειδοποιούμε το Cluster ότι κάναμε update!
- 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/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/<list_id>', 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/<ip>')
- @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/<action>')
- @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))
-
- # BUG FIX: Διπλός έλεγχος os.path.exists() για να μην κρασάρει το zip
- 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 {}
- if not data or 'domain' not in data:
- return jsonify({"status": "error", "message": "Missing parameters"}), 400
-
- domain = re.sub(r'[^a-zA-Z0-9\.\-\/]', '', data['domain']).replace('../', '')
-
- # --- Η ΝΕΑ ΛΟΓΙΚΗ ---
- # 1. Αν ο Master μας έστειλε λίστα, τη χρησιμοποιούμε αυτούσια
- if data.get('masters'):
- masters_list = data['masters']
- else:
- # 2. Fallback στην IP αυτού που μας κάλεσε (αν δεν στάλθηκαν IPs)
- master_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
- masters_list = [master_ip]
-
- # Μετατροπή της λίστας σε string για το BIND config (π.χ. "1.1.1.1; 2.2.2.2;")
- masters_string = "".join([f" {ip};" for ip in masters_list])
- conf = fm.read_file(config.LOCAL_CONF)
- if f'zone "{domain}"' in conf:
- return jsonify({"status": "error", "message": "Zone already exists"}), 409
-
- path = os.path.join(config.CACHE_PATH, f"db.{domain}")
- conf += 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)
- subprocess.run(["rndc", "reconfig"])
- fm.log_audit("API_SYNC", domain, f"Auto-created Slave (Masters: {masters_string})")
-
- return jsonify({"status": "success", "message": f"Slave zone {domain} configured with masters: {masters_string}"})
- @app.route('/api/v1/cluster/zone/<domain>', 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():
- # ΑΦΑΙΡΕΘΗΚΕ ΤΟ AUTO-LINKING! Το Ping πλέον απλά επιβεβαιώνει ότι το API Key είναι σωστό.
- 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"})
- # --- REMOTE SERVICE CONTROL (SLAVE RECEIVER) ---
- @app.route('/api/v1/cluster/service', methods=['POST'])
- @api_required
- def api_cluster_service():
- # Fix: Ασφαλής ανάγνωση του JSON, χωρίς να σκάει
- data = request.get_json(force=True, silent=True) or {}
- action = data.get('action')
-
- if action in ['start', 'stop', 'restart', 'reload']:
- try:
- # Fix: Πιάνουμε το πραγματικό error του systemctl αν αποτύχει
- 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():
- # Fix: Απαραίτητο import για να μην σκάει η urllib αν πάρει Error από τον Slave
- import urllib.request, urllib.error
-
- try:
- data = request.get_json(force=True, silent=True) or {}
- node_name = data.get('node_name')
- action = data.get('action')
-
- 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", "message": "Node not found."})
-
- url = f"{node['url'].rstrip('/')}/api/v1/cluster/service"
- payload = {"action": action}
-
- proxy_handler = urllib.request.ProxyHandler({})
- https_handler = urllib.request.HTTPSHandler(context=config.ssl_ctx)
- opener = urllib.request.build_opener(proxy_handler, https_handler)
-
- 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')
-
- # Fix: Timeout στα 15 δευτερόλεπτα! Το BIND μπορεί να κάνει ώρα να κάνει restart
- 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 returned HTTP Error {e.code}. Check API keys."})
- 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)
- # Βρίσκουμε όλα τα domains που είναι "type master"
- zones = re.findall(r'zone\s+"([^"]+)"\s*\{.*?type\s+master;', conf, re.DOTALL)
- # Εξαιρούμε το RPZ, θα συγχρονιστεί από το AdBlock μενού
- zones = [z for z in zones if z != "rpz.blacklist"]
-
- if not zones:
- return jsonify({"status": "error", "message": "No Master zones found to sync."})
-
- def bulk_sync():
- for z in zones:
- cm.broadcast_to_cluster('add_slave', z)
- time.sleep(0.5) # Μικρή καθυστέρηση για να μην "μπουκώσει" ο Slave
-
- import threading
- threading.Thread(target=bulk_sync, daemon=True).start()
- return jsonify({"status": "success", "message": f"Started pushing {len(zones)} zones to all Nodes!"})
- if __name__ == '__main__':
- # Είναι σημαντικό το threaded=True για να μην κάνει "block" το UI στο self-ping
- app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|