diff --git a/app.py b/app.py index 1986d6f..2027b2f 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,16 @@ -from flask import Flask, render_template, render_template_string -import configparser +import os +import sys import ssl + +import configparser +from flask import Flask, render_template, render_template_string from routes.main_routes import main_bp from routes.edit_routes import edit_bp from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats from auth.auth_middleware import setup_auth from log_parser import parse_log_file -import os -import sys -from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends +from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends BASE_DIR = os.path.abspath(os.path.dirname(__file__)) @@ -73,23 +74,23 @@ try: certificate_path = config2.get('ssl', 'certificate_path') private_key_path = config2.get('ssl', 'private_key_path') else: - print(f"[APP] ✗ No [ssl] section in {SSL_INI}", flush=True) + print(f"[APP] No [ssl] section in {SSL_INI}", flush=True) sys.exit(1) if not os.path.exists(certificate_path): - print(f"[APP] ✗ Certificate not found: {certificate_path}", flush=True) + print(f"[APP] Certificate not found: {certificate_path}", flush=True) sys.exit(1) if not os.path.exists(private_key_path): - print(f"[APP] ✗ Private key not found: {private_key_path}", flush=True) + print(f"[APP] Private key not found: {private_key_path}", flush=True) sys.exit(1) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path) - print(f"[APP] ✓ SSL context loaded", flush=True) + print(f"[APP] SSL context loaded", flush=True) except Exception as e: - print(f"[APP] ✗ SSL error: {e}", flush=True) + print(f"[APP] SSL error: {e}", flush=True) sys.exit(1) @@ -99,11 +100,28 @@ def display_haproxy_stats(): parsed_stats = parse_haproxy_stats(haproxy_stats) return render_template('statistics.html', stats=parsed_stats) -@app.route('/logs') -def display_logs(): + +@app.route('/logs', endpoint='display_logs') +#@requires_auth +def display_haproxy_logs(): log_file_path = '/var/log/haproxy.log' - parsed_entries = parse_log_file(log_file_path) - return render_template('logs.html', entries=parsed_entries) + + if not os.path.exists(log_file_path): + return render_template('logs.html', + logs=[], + error_message=f"Log file not found: {log_file_path}") + + try: + logs = parse_log_file(log_file_path) + if not logs: + return render_template('logs.html', + logs=[], + error_message="Log file is empty or unreadable") + return render_template('logs.html', logs=logs) + except Exception as e: + return render_template('logs.html', + logs=[], + error_message=f"Error parsing logs: {str(e)}") @app.route('/home') diff --git a/log_parser.py b/log_parser.py index 414b506..330d489 100644 --- a/log_parser.py +++ b/log_parser.py @@ -1,7 +1,13 @@ import re def parse_log_file(log_file_path): + """ + Parse HAProxy syslog format and identify security threats. + Format: <134>Nov 3 09:18:35 haproxy[18]: IP:PORT [DATE:TIME] FRONTEND BACKEND STATUS BYTES ... + """ parsed_entries = [] + + # Security threat patterns xss_patterns = [ r'<\s*script\s*', r'javascript:', @@ -12,92 +18,118 @@ def parse_log_file(log_file_path): r'<\s*input\s*[^>]*\s*value\s*=?', r'<\s*form\s*action\s*=?', r'<\s*svg\s*on\w+\s*=?', - r'script', - r'alert', + r'alert\s*\(', r'onerror', r'onload', - r'javascript' ] - + sql_patterns = [ - r';', - r'substring', - r'extract', - r'union\s+all', - r'order\s+by', + r'(union|select|insert|update|delete|drop)\s+(from|into|table)', + r';\s*(union|select|insert|update|delete|drop)', + r'substring\s*\(', + r'extract\s*\(', + r'order\s+by\s+\d+', r'--\+', - r'union', - r'select', - r'insert', - r'update', - r'delete', - r'drop', - r'@@', - r'1=1', + r'1\s*=\s*1', + r'@@\w+', r'`1', - r'union', - r'select', - r'insert', - r'update', - r'delete', - r'drop', - r'@@', - r'1=1', - r'`1' ] - + webshells_patterns = [ - r'payload', - r'eval|system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec|cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil.*\.php.*' + r'eval\s*\(', + r'system\s*\(', + r'passthru\s*\(', + r'shell_exec\s*\(', + r'exec\s*\(', + r'popen\s*\(', + r'proc_open\s*\(', + r'backdoor|webshell|phpspy|c99|kacak|b374k|wsos', ] - - combined_xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE) - combined_sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE) - combined_webshells_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE) - - with open(log_file_path, 'r') as log_file: - log_lines = log_file.readlines() + + # Compile patterns + xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE) + sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE) + webshell_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE) + + try: + with open(log_file_path, 'r', encoding='utf-8', errors='ignore') as log_file: + log_lines = log_file.readlines() + for line in log_lines: - if " 403 " in line: # Check if the line contains " 403 " indicating a 403 status code - match = re.search(r'(\w+\s+\d+\s\d+:\d+:\d+).*\s(\d+\.\d+\.\d+\.\d+).*"\s*(GET|POST|PUT|DELETE)\s+([^"]+)"', line) - if match: - timestamp = match.group(1) # Extract the date and time - ip_address = match.group(2) - http_method = match.group(3) - requested_url = match.group(4) + if not line.strip(): + continue - if combined_xss_pattern.search(line): - xss_alert = 'Possible XSS Attack Was Identified.' - else: - xss_alert = '' - if combined_sql_pattern.search(line): - sql_alert = 'Possible SQL Injection Attempt Was Made.' - else: - sql_alert = '' - if "PUT" in line: - put_method = 'Possible Remote File Upload Attempt Was Made.' - else: - put_method = '' - - if "admin" in line: - illegal_resource = 'Possible Illegal Resource Access Attempt Was Made.' - else: - illegal_resource = '' - - if combined_webshells_pattern.search(line): - webshell_alert = 'Possible WebShell Attack Attempt Was Made.' - else: - webshell_alert = '' - - parsed_entries.append({ - 'timestamp': timestamp, - 'ip_address': ip_address, - 'http_method': http_method, - 'requested_url': requested_url, - 'xss_alert': xss_alert, - 'sql_alert': sql_alert, - 'put_method': put_method, - 'illegal_resource': illegal_resource, - 'webshell_alert': webshell_alert - }) - return parsed_entries \ No newline at end of file + try: + # Extract syslog header + syslog_match = re.search( + r'<\d+>(\w+\s+\d+\s+\d+:\d+:\d+).*haproxy\[\d+\]:\s+', + line + ) + + if not syslog_match: + continue + + timestamp = syslog_match.group(1) + + # Extract IP:PORT + ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', line) + if not ip_match: + continue + + ip_address = ip_match.group(1) + + # Extract date/time in brackets + datetime_match = re.search(r'\[(\d{2}/\w+/\d{4}:\d{2}:\d{2}:\d{2})', line) + if datetime_match: + timestamp = datetime_match.group(1) + + # Extract frontend and backend + fe_be_match = re.search(r'\]\s+(\S+)\s+(\S+)\s+(\d+/\d+/\d+/\d+/\d+)\s+(\d{3})', line) + if not fe_be_match: + continue + + frontend = fe_be_match.group(1) + backend = fe_be_match.group(2) + status_code = fe_be_match.group(4) + + # Extract HTTP method and URL + http_match = re.search(r'"(\w+)\s+([^\s]+)\s+HTTP', line) + if not http_match: + continue + + http_method = http_match.group(1) + requested_url = http_match.group(2) + + # Detect threats + xss_alert = bool(xss_pattern.search(line)) + sql_alert = bool(sql_pattern.search(line)) + webshell_alert = bool(webshell_pattern.search(line)) + put_method = http_method == 'PUT' + illegal_resource = status_code == '403' + + parsed_entries.append({ + 'timestamp': timestamp, + 'ip_address': ip_address, + 'http_method': http_method, + 'requested_url': requested_url, + 'status_code': status_code, + 'frontend': frontend, + 'backend': backend, + 'xss_alert': xss_alert, + 'sql_alert': sql_alert, + 'put_method': put_method, + 'illegal_resource': illegal_resource, + 'webshell_alert': webshell_alert, + }) + except Exception as e: + print(f"Error parsing line: {e}") + continue + + except FileNotFoundError: + print(f"Log file not found: {log_file_path}") + return [] + except Exception as e: + print(f"Error reading log file: {e}") + return [] + + return parsed_entries diff --git a/routes/edit_routes.py b/routes/edit_routes.py index b05024d..e9f6179 100644 --- a/routes/edit_routes.py +++ b/routes/edit_routes.py @@ -8,84 +8,119 @@ edit_bp = Blueprint('edit', __name__) @requires_auth def edit_haproxy_config(): if request.method == 'POST': - edited_config = request.form['haproxy_config'] - + edited_config = request.form.get('haproxy_config', '') + action = request.form.get('action', 'check') + + print(f"[EDIT] POST action: {action}", flush=True) + try: with open('/etc/haproxy/haproxy.cfg', 'w') as f: f.write(edited_config) + print(f"[EDIT] Configuration saved successfully", flush=True) except Exception as e: + print(f"[EDIT] Error writing config: {e}", flush=True) return render_template( 'edit.html', config_content=edited_config, check_output=f"Error writing configuration: {e}", check_level="danger" ) - - def run_check(): + + check_output = "" + check_level = "success" + + try: result = subprocess.run( ['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True + text=True, + timeout=10 ) - out = (result.stdout or '').strip() - + + check_output = (result.stdout or '').strip() + if result.returncode == 0: - if not out: - out = "Configuration file is valid ✅" - level = "success" - if "Warning" in out or "Warnings" in out: - level = "warning" - else: - if not out: - out = f"Check failed with return code {result.returncode}" - level = "danger" - - return result.returncode, out, level - - check_output = "" - check_level = "success" - - if 'save_check' in request.form: - _, check_output, check_level = run_check() - - elif 'save_reload' in request.form: - rc, out, level = run_check() - check_output, check_level = out, level - - if rc == 0: - try: - supervisor_result = subprocess.run( - ['pkill', '-f', 'haproxy'], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=10 - ) - if supervisor_result.returncode == 0: - check_output += f"\n\nHAProxy Restarted:\n{supervisor_result.stdout}" - else: - check_output += ( - f"\n\nRestart attempt returned {supervisor_result.returncode}:\n" - f"{supervisor_result.stdout}" - ) - except Exception as e: - check_output += f"\n\nRestart failed: {e}" + if not check_output: + check_output = "Configuration file is valid" + check_level = "success" + + if "Warning" in check_output or "Warnings" in check_output: check_level = "warning" - + check_output = f"⚠ {check_output}" + else: + check_output = f"✓ {check_output}" + + print(f"[EDIT] Config validation: SUCCESS", flush=True) + else: + if not check_output: + check_output = f"Check failed with return code {result.returncode}" + check_output = f"✗ {check_output}" + check_level = "danger" + print(f"[EDIT] Config validation: FAILED - {check_output}", flush=True) + + except subprocess.TimeoutExpired: + check_output = "✗ Configuration check timed out" + check_level = "danger" + print(f"[EDIT] Config validation: TIMEOUT", flush=True) + except Exception as e: + check_output = f"✗ Error checking config: {e}" + check_level = "danger" + print(f"[EDIT] Config validation ERROR: {e}", flush=True) + + if action == "save" and check_level == "success": + print(f"[EDIT] Attempting HAProxy restart...", flush=True) + try: + restart_result = subprocess.run( + ['pkill', '-f', 'haproxy'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10 + ) + + if restart_result.returncode == 0 or 'No such process' in restart_result.stdout: + check_output += "\n\n✓ HAProxy restart signal sent successfully" + check_output += "\n(supervisord will restart the process)" + print(f"[EDIT] HAProxy restart successful", flush=True) + else: + check_output += f"\n\n⚠ Restart returned code {restart_result.returncode}" + if restart_result.stdout: + check_output += f"\nOutput: {restart_result.stdout}" + check_level = "warning" + print(f"[EDIT] Restart warning: {restart_result.stdout}", flush=True) + + except subprocess.TimeoutExpired: + check_output += "\n\n⚠ Restart command timed out" + check_level = "warning" + print(f"[EDIT] Restart TIMEOUT", flush=True) + except Exception as e: + check_output += f"\n\n⚠ Restart error: {e}" + check_level = "warning" + print(f"[EDIT] Restart ERROR: {e}", flush=True) + + print(f"[EDIT] Returning check_level={check_level}, output length={len(check_output)}", flush=True) + return render_template( 'edit.html', config_content=edited_config, check_output=check_output, check_level=check_level ) - + + # GET request - load current config try: with open('/etc/haproxy/haproxy.cfg', 'r') as f: config_content = f.read() + print(f"[EDIT] Config loaded successfully ({len(config_content)} bytes)", flush=True) except FileNotFoundError: - config_content = "# HAProxy configuration file not found\n# Please create /etc/haproxy/haproxy.cfg" + config_content = "# HAProxy configuration file not found\n# Please create /etc/haproxy/haproxy.cfg\n" + print(f"[EDIT] Config file not found", flush=True) except PermissionError: - config_content = "# Permission denied reading HAProxy configuration file" - + config_content = "# Permission denied reading HAProxy configuration file\n" + print(f"[EDIT] Permission denied reading config", flush=True) + except Exception as e: + config_content = f"# Error reading config: {e}\n" + print(f"[EDIT] Error reading config: {e}", flush=True) + return render_template('edit.html', config_content=config_content) diff --git a/routes/main_routes.py b/routes/main_routes.py index 95a5436..4fbc5fd 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -1,104 +1,252 @@ from flask import Blueprint, render_template, request -from auth.auth_middleware import requires_auth # Updated import -from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends +import subprocess +from auth.auth_middleware import requires_auth +from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends main_bp = Blueprint('main', __name__) - +def reload_haproxy(): + """Reload HAProxy by killing it - supervisord restarts automatically""" + try: + # Validate config first + result = subprocess.run( + ['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10 + ) + + if result.returncode != 0: + return False, f"Config validation failed: {result.stdout}" + + # Kill haproxy - supervisord will restart it automatically + result = subprocess.run( + ['pkill', '-f', 'haproxy'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10 + ) + + if result.returncode == 0 or 'No such process' in result.stdout: + print("[HAPROXY] Process killed, supervisord will restart", flush=True) + return True, "HAProxy restarted successfully" + else: + print(f"[HAPROXY] pkill failed: {result.stdout}", flush=True) + return False, f"pkill failed: {result.stdout}" + except Exception as e: + print(f"[HAPROXY] Error: {e}", flush=True) + return False, f"Error: {str(e)}" @main_bp.route('/', methods=['GET', 'POST']) @requires_auth def index(): if request.method == 'POST': - frontend_name = request.form['frontend_name'] + # Frontend IP i port frontend_ip = request.form['frontend_ip'] frontend_port = request.form['frontend_port'] + frontend_hostname = request.form.get('frontend_hostname', '').strip() + lb_method = request.form['lb_method'] protocol = request.form['protocol'] backend_name = request.form['backend_name'] + + # Header options add_header = 'add_header' in request.form header_name = request.form.get('header_name', '') if add_header else '' header_value = request.form.get('header_value', '') if add_header else '' - - # Get all backend servers data + # Server header removal + del_server_header = 'del_server_header' in request.form + + # Backend SSL redirect + backend_ssl_redirect = 'backend_ssl_redirect' in request.form + ssl_redirect_backend_name = request.form.get('ssl_redirect_backend_name', '').strip() if backend_ssl_redirect else '' + ssl_redirect_port = request.form.get('ssl_redirect_port', '80') + + # Backend servers backend_server_names = request.form.getlist('backend_server_names[]') backend_server_ips = request.form.getlist('backend_server_ips[]') backend_server_ports = request.form.getlist('backend_server_ports[]') backend_server_maxconns = request.form.getlist('backend_server_maxconns[]') - - is_acl = 'add_acl' in request.form - acl_name = request.form['acl'] if 'acl' in request.form else '' - acl_action = request.form['acl_action'] if 'acl_action' in request.form else '' - acl_backend_name = request.form['backend_name_acl'] if 'backend_name_acl' in request.form else '' + + # Custom ACL + add_custom_acl = 'add_custom_acl' in request.form + custom_acl_name = request.form.get('custom_acl_name', '').strip() if add_custom_acl else '' + custom_acl_type = request.form.get('custom_acl_type', 'path_beg') if add_custom_acl else '' + custom_acl_value = request.form.get('custom_acl_value', '').strip() if add_custom_acl else '' + custom_acl_action = request.form.get('custom_acl_action', 'route') if add_custom_acl else '' + custom_acl_backend = request.form.get('custom_acl_backend', '').strip() if add_custom_acl else '' + custom_acl_redirect_url = request.form.get('custom_acl_redirect_url', '').strip() if add_custom_acl else '' + + # SSL use_ssl = 'ssl_checkbox' in request.form - ssl_cert_path = request.form['ssl_cert_path'] + ssl_cert_path = request.form.get('ssl_cert_path', '/app/ssl/haproxy-configurator.pem') https_redirect = 'ssl_redirect_checkbox' in request.form - is_dos = 'add_dos' in request.form if 'add_dos' in request.form else '' - ban_duration = request.form["ban_duration"] - limit_requests = request.form["limit_requests"] + + # DOS Protection + is_dos = 'add_dos' in request.form + ban_duration = request.form.get('ban_duration', '30m') + limit_requests = request.form.get('limit_requests', '100') + + # Forward For forward_for = 'forward_for_check' in request.form - - is_forbidden_path = 'add_acl_path' in request.form - forbidden_name = request.form["forbidden_name"] - allowed_ip = request.form["allowed_ip"] - forbidden_path = request.form["forbidden_path"] - - sql_injection_check = 'sql_injection_check' in request.form if 'sql_injection_check' in request.form else '' - is_xss = 'xss_check' in request.form if 'xss_check' in request.form else '' - is_remote_upload = 'remote_uploads_check' in request.form if 'remote_uploads_check' in request.form else '' - + + # SQL Injection + sql_injection_check = 'sql_injection_check' in request.form + + # XSS + is_xss = 'xss_check' in request.form + + # Remote uploads + is_remote_upload = 'remote_uploads_check' in request.form + + # Webshells + is_webshells = 'webshells_check' in request.form + + # Path-based redirects (legacy) add_path_based = 'add_path_based' in request.form - redirect_domain_name = request.form["redirect_domain_name"] - root_redirect = request.form["root_redirect"] - redirect_to = request.form["redirect_to"] - is_webshells = 'webshells_check' in request.form if 'webshells_check' in request.form else '' - - # Combine backend server info into a list of tuples (name, ip, port, maxconns) + redirect_domain_name = request.form.get('redirect_domain_name', '') + root_redirect = request.form.get('root_redirect', '') + redirect_to = request.form.get('redirect_to', '') + + # Forbidden paths (legacy) + is_forbidden_path = 'add_acl_path' in request.form + forbidden_name = request.form.get('forbidden_name', '') + allowed_ip = request.form.get('allowed_ip', '') + forbidden_path = request.form.get('forbidden_path', '') + + # Build backend_servers list backend_servers = [] for i in range(len(backend_server_ips)): name = backend_server_names[i] if i < len(backend_server_names) else f"server{i+1}" ip = backend_server_ips[i] if i < len(backend_server_ips) else '' port = backend_server_ports[i] if i < len(backend_server_ports) else '' maxconn = backend_server_maxconns[i] if i < len(backend_server_maxconns) else None - - if ip and port: # Only add if we have IP and port + if ip and port: backend_servers.append((name, ip, port, maxconn)) - # Check if frontend or port already exists - if is_frontend_exist(frontend_name, frontend_ip, frontend_port): - return render_template('index.html', message="Frontend or Port already exists. Cannot add duplicate.") - - # Get health check related fields if the protocol is HTTP + # Health checks health_check = False health_check_link = "" if protocol == 'http': health_check = 'health_check' in request.form if health_check: - health_check_link = request.form['health_check_link'] - + health_check_link = request.form.get('health_check_link', '/') + health_check_tcp = False if protocol == 'tcp': health_check_tcp = 'health_check2' in request.form - - # Get sticky session related fields + + # Sticky session sticky_session = False sticky_session_type = "" if 'sticky_session' in request.form: sticky_session = True - sticky_session_type = request.form['sticky_session_type'] - - # Update the HAProxy config file + sticky_session_type = request.form.get('sticky_session_type', 'cookie') + + # Legacy ACL (unused, kept for compatibility) + is_acl = False + acl_name = '' + acl_action = '' + acl_backend_name = '' + + # Frontend name (None - will be generated) + frontend_name = None + + # Call update_haproxy_config message = update_haproxy_config( - frontend_name, frontend_ip, frontend_port, lb_method, protocol, backend_name, - backend_servers, health_check, health_check_tcp, health_check_link, sticky_session, - add_header, header_name, header_value, sticky_session_type, is_acl, acl_name, - acl_action, acl_backend_name, use_ssl, ssl_cert_path, https_redirect, is_dos, - ban_duration, limit_requests, forward_for, is_forbidden_path, forbidden_name, - allowed_ip, forbidden_path, sql_injection_check, is_xss, is_remote_upload, - add_path_based, redirect_domain_name, root_redirect, redirect_to, is_webshells + frontend_name=frontend_name, + frontend_ip=frontend_ip, + frontend_port=frontend_port, + lb_method=lb_method, + protocol=protocol, + backend_name=backend_name, + backend_servers=backend_servers, + health_check=health_check, + health_check_tcp=health_check_tcp, + health_check_link=health_check_link, + sticky_session=sticky_session, + add_header=add_header, + header_name=header_name, + header_value=header_value, + sticky_session_type=sticky_session_type, + is_acl=is_acl, + acl_name=acl_name, + acl_action=acl_action, + acl_backend_name=acl_backend_name, + use_ssl=use_ssl, + ssl_cert_path=ssl_cert_path, + https_redirect=https_redirect, + is_dos=is_dos, + ban_duration=ban_duration, + limit_requests=limit_requests, + forward_for=forward_for, + is_forbidden_path=is_forbidden_path, + forbidden_name=forbidden_name, + allowed_ip=allowed_ip, + forbidden_path=forbidden_path, + sql_injection_check=sql_injection_check, + is_xss=is_xss, + is_remote_upload=is_remote_upload, + add_path_based=add_path_based, + redirect_domain_name=redirect_domain_name, + root_redirect=root_redirect, + redirect_to=redirect_to, + is_webshells=is_webshells, + del_server_header=del_server_header, + backend_ssl_redirect=backend_ssl_redirect, + ssl_redirect_backend_name=ssl_redirect_backend_name, + ssl_redirect_port=ssl_redirect_port, + frontend_hostname=frontend_hostname, + add_custom_acl=add_custom_acl, + custom_acl_name=custom_acl_name, + custom_acl_type=custom_acl_type, + custom_acl_value=custom_acl_value, + custom_acl_action=custom_acl_action, + custom_acl_backend=custom_acl_backend, + custom_acl_redirect_url=custom_acl_redirect_url ) - return render_template('index.html', message=message) - - return render_template('index.html') - + + # ===== DETERMINE MESSAGE TYPE ===== + message_type = "success" # Default + + # Check for ERROR conditions + if "error" in message.lower(): + message_type = "danger" + elif "failed" in message.lower(): + message_type = "danger" + elif "already exists" in message.lower(): + message_type = "danger" + elif "cannot add" in message.lower(): + message_type = "danger" + # SUCCESS conditions + elif "configuration updated successfully" in message.lower(): + message_type = "success" + elif "backend added to existing" in message.lower(): + message_type = "success" + + # ===== RELOAD HAPROXY (JEŚLI SUCCESS) ===== + if message_type == "success": + reload_ok, reload_msg = reload_haproxy() + if reload_ok: + message = message + " ✓ " + reload_msg + message_type = "success" + else: + message = message + " ⚠ " + reload_msg + message_type = "warning" + + return render_template('index.html', + message=message, + message_type=message_type) + + # GET request - display stats + frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends() + + return render_template('index.html', + frontend_count=frontend_count, + backend_count=backend_count, + acl_count=acl_count, + layer7_count=layer7_count, + layer4_count=layer4_count) diff --git a/spawn.sh b/spawn.sh index 7696804..aa416ff 100644 --- a/spawn.sh +++ b/spawn.sh @@ -1,3 +1,5 @@ #!/bin/bash +git pull docker compose down -docker compose up --build +docker compose up --remove-orphans --build --no-deps --force-recreate + diff --git a/static/css/edit.css b/static/css/edit.css new file mode 100644 index 0000000..faff06e --- /dev/null +++ b/static/css/edit.css @@ -0,0 +1,48 @@ +.CodeMirror { + height: 500px !important; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 13px; + border: none; +} + +.CodeMirror-gutters { + background-color: #263238; + border-right: 1px solid #37474f; +} + +.CodeMirror-linenumber { + color: #546e7a; +} + +.CodeMirror-cursor { + border-left: 1px solid #fff; +} + +#haproxy_config { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 13px; + line-height: 1.5; + padding: 12px; + border: none; + width: 100%; + overflow: hidden; + resize: none; + background: #1e1e1e; + color: #e8e8e8; +} + +#edit_form button { + white-space: nowrap; +} + +@media (max-width: 768px) { + .CodeMirror { + height: 300px !important; + font-size: 12px; + } + + #haproxy_config { + font-size: 12px; + min-height: 300px; + } +} \ No newline at end of file diff --git a/static/js/editor.js b/static/js/editor.js new file mode 100644 index 0000000..bce5b37 --- /dev/null +++ b/static/js/editor.js @@ -0,0 +1,133 @@ +/** + * HAProxy Configuration Editor + * Auto-grow textarea + CodeMirror integration + */ + +document.addEventListener('DOMContentLoaded', function() { + // Auto-grow textarea (fallback if CodeMirror fails) + initAutoGrowTextarea(); + + // Try to initialize CodeMirror + initCodeMirror(); +}); + +/** + * Initialize auto-grow textarea + */ +function initAutoGrowTextarea() { + 'use strict'; + const ta = document.getElementById('haproxy_config'); + if (!ta) return; + + const autoGrow = () => { + ta.style.height = 'auto'; + ta.style.height = (ta.scrollHeight + 6) + 'px'; + }; + + ta.addEventListener('input', autoGrow); + ta.addEventListener('change', autoGrow); + + // Initial auto-size + autoGrow(); + + // Resize on window resize + window.addEventListener('resize', autoGrow); + + console.log('[Editor] Auto-grow textarea initialized'); +} + +/** + * Initialize CodeMirror editor + */ +function initCodeMirror() { + 'use strict'; + + // Check if CodeMirror is available + if (typeof CodeMirror === 'undefined') { + console.warn('[Editor] CodeMirror not loaded, using fallback textarea'); + document.getElementById('haproxy_config').style.display = 'block'; + return; + } + + try { + const editorElement = document.getElementById('haproxy_editor'); + if (!editorElement) { + console.warn('[Editor] haproxy_editor element not found'); + return; + } + + const editor = CodeMirror.fromTextArea(editorElement, { + lineNumbers: true, + lineWrapping: true, + indentUnit: 4, + indentWithTabs: false, + theme: 'material-darker', + mode: 'text/x-nginx-conf', + styleActiveLine: true, + styleSelectedText: true, + highlightSelectionMatches: { annotateScrollbar: true }, + foldGutter: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + matchBrackets: true, + autoCloseBrackets: true, + extraKeys: { + 'Ctrl-S': function() { + document.querySelector('button[value="save"]').click(); + }, + 'Ctrl-L': function() { + editor.clearHistory(); + }, + 'Ctrl-/': 'toggleComment' + } + }); + + // Hide fallback textarea + document.getElementById('haproxy_config').style.display = 'none'; + + // Update line/col info + editor.on('cursorActivity', function() { + const pos = editor.getCursor(); + document.getElementById('line_col').textContent = + `Line ${pos.line + 1}, Col ${pos.ch + 1}`; + document.getElementById('char_count').textContent = + editor.getValue().length; + }); + + // Auto-save to localStorage + let saveTimeout; + editor.on('change', function() { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + localStorage.setItem('haproxy_draft', editor.getValue()); + }, 1000); + }); + + // Recover from localStorage + const draft = localStorage.getItem('haproxy_draft'); + const currentContent = editorElement.value.trim(); + + if (draft && draft.trim() !== currentContent && currentContent === '') { + if (confirm('📝 Recover unsaved draft?')) { + editor.setValue(draft); + localStorage.removeItem('haproxy_draft'); + } + } + + // Form submission - sync values + const editForm = document.getElementById('edit_form'); + editForm.addEventListener('submit', function(e) { + editorElement.value = editor.getValue(); + document.getElementById('haproxy_config').value = editor.getValue(); + }); + + // Initial info + document.getElementById('char_count').textContent = editor.getValue().length; + + console.log('[Editor] CodeMirror initialized successfully'); + + } catch (e) { + console.warn('[Editor] CodeMirror initialization failed:', e); + // Fallback textarea is already visible + document.getElementById('haproxy_config').style.display = 'block'; + } +} diff --git a/static/js/form.js b/static/js/form.js new file mode 100644 index 0000000..f2261fb --- /dev/null +++ b/static/js/form.js @@ -0,0 +1,137 @@ +(() => { + 'use strict'; + + // Helper functions (shared) + const $ = (sel, root = document) => root.querySelector(sel); + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + + // ===== HEALTH CHECK FIELDS (Protocol-dependent) ===== + const protocolSelect = document.getElementById('protocol'); + const healthCheckFields = document.getElementById('health_check_fields'); + const tcpHealthCheck = document.getElementById('tcp_health_check'); + + const onProtocolChange = () => { + if (protocolSelect?.value === 'http') { + const healthCheckParent = document.getElementById('health_check')?.parentElement.parentElement; + if (healthCheckParent) healthCheckParent.style.display = 'block'; + if (tcpHealthCheck) tcpHealthCheck.style.display = 'none'; + } else { + const healthCheckParent = document.getElementById('health_check')?.parentElement.parentElement; + if (healthCheckParent) healthCheckParent.style.display = 'none'; + if (tcpHealthCheck) tcpHealthCheck.style.display = 'flex'; + } + }; + + protocolSelect?.addEventListener('change', onProtocolChange); + + // ===== STICKY SESSION FIELDS ===== + const stickyCheckbox = document.getElementById('sticky_session'); + const stickyFields = document.getElementById('sticky_fields'); + + stickyCheckbox?.addEventListener('change', function() { + stickyFields?.classList.toggle('d-none', !this.checked); + }); + + // ===== HEALTH CHECK LINK FIELD ===== + const healthCheckbox = document.getElementById('health_check'); + healthCheckbox?.addEventListener('change', function() { + document.getElementById('health_check_fields')?.classList.toggle('d-none', !this.checked); + }); + + // ===== CUSTOM HEADER FIELDS ===== + const headerCheckbox = document.getElementById('add_header'); + const headerFields = document.querySelectorAll('#header_fields'); + + headerCheckbox?.addEventListener('change', function() { + headerFields.forEach(field => field.classList.toggle('d-none', !this.checked)); + }); + + // ===== NO-LB MODE HANDLING ===== + const lbMethodSelect = $('#lb_method'); + const backendServersContainer = $('#backend_servers_container'); + const addServerBtn = $('#add_backend_btn'); + + const onLbMethodChange = () => { + const isNoLb = lbMethodSelect?.value === 'no-lb'; + + if (isNoLb) { + // Hide add server button + if (addServerBtn) addServerBtn.classList.add('d-none'); + + // Keep only first server and remove others + const serverRows = $$('.backend-server-row', backendServersContainer); + serverRows.forEach((row, idx) => { + if (idx > 0) row.remove(); + }); + + // Add info about no-lb mode if it doesn't exist + if (!$('.no-lb-info')) { + const info = document.createElement('div'); + info.className = 'alert alert-info alert-sm no-lb-info mt-2'; + info.innerHTML = 'Mode no-lb: frontend → backend → single server. You can still enable XSS, DOS, SQL injection protection etc.'; + if (backendServersContainer?.parentElement) { + backendServersContainer.parentElement.appendChild(info); + } + } + } else { + // Show add server button + if (addServerBtn) addServerBtn.classList.remove('d-none'); + + // Remove no-lb info + const info = $('.no-lb-info'); + if (info) info.remove(); + } + }; + + lbMethodSelect?.addEventListener('change', onLbMethodChange); + if (lbMethodSelect) onLbMethodChange(); + + // ===== BACKEND SERVER ROWS (Dynamic Add/Remove) ===== + let serverCount = 1; + const container = $('#backend_servers_container'); + const addBtn = $('#add_backend_btn'); + + const createRow = () => { + serverCount++; + const row = document.createElement('div'); + row.className = 'row g-3 backend-server-row mt-1'; + row.innerHTML = ` +
{{ check_output }}
-