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 = ` +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ `; + + const removeBtn = row.querySelector('.remove-server'); + removeBtn.addEventListener('click', () => row.remove()); + + return row; + }; + + addBtn?.addEventListener('click', () => { + if (container) { + container.appendChild(createRow()); + } + }); + + // Remove button for dynamically added rows + container?.addEventListener('click', (e) => { + if (e.target.closest('.remove-server')) { + e.target.closest('.backend-server-row').remove(); + } + }); + +})(); diff --git a/static/js/index.js b/static/js/index.js index cdb311c..6098631 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,105 +1,82 @@ (() => { - 'use strict'; - - const $ = (sel, root=document) => root.querySelector(sel); - const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel)); - - // SSL fields - const sslCheckbox = $('#ssl_checkbox'); - const sslFields = $('#ssl_fields'); - - const toggle = (on, el) => el.classList.toggle('d-none', !on); - - sslCheckbox?.addEventListener('change', () => toggle(sslCheckbox.checked, sslFields)); - - // DOS - const dosCheckbox = $('#add_dos'); - const dosFields = $('#dos_fields'); - dosCheckbox?.addEventListener('change', () => toggle(dosCheckbox.checked, dosFields)); - - // HTTP only groups - const protocolSelect = $('#protocol'); - const httpGroups = $$('.http-only, #forbidden_acl_container'); - const httpToggles = [ - $('#sql_injection_check'), - $('#xss_check'), - $('#remote_uploads_check'), - $('#webshells_check'), - $('#forward_for_check'), - $('#add_acl_path'), - $('#add_path_based'), - ]; - const forbiddenFields = $('#forbidden_fields'); - const pathFields = $('#base_redirect_fields'); - - const onProtocolChange = () => { - const isHttp = protocolSelect?.value === 'http'; - httpGroups.forEach(el => toggle(isHttp, el)); - if (!isHttp) { - // hide optional groups if protocol != http - [forbiddenFields, pathFields].forEach(el => el && toggle(false, el)); - httpToggles.forEach(input => { if (input) input.checked = false; }); - } - }; - protocolSelect?.addEventListener('change', onProtocolChange); - onProtocolChange(); - - // ACL - const aclCheckbox = $('#add_acl'); - const aclFields = $('#acl_fields'); - aclCheckbox?.addEventListener('change', () => toggle(aclCheckbox.checked, aclFields)); - - // toggles that reveal their fields - const bindToggle = (checkboxSel, targetSel) => { - const cb = $(checkboxSel); - const target = $(targetSel); - cb?.addEventListener('change', () => toggle(cb.checked, target)); - // initial - if (cb && target) toggle(cb.checked, target); - }; - bindToggle('#add_path_based', '#base_redirect_fields'); - bindToggle('#add_acl_path', '#forbidden_fields'); - - // Backend rows - 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 = ` -
- - -
-
- - -
-
- - -
-
- -
- - -
-
`; - row.querySelector('button.btn-danger')?.addEventListener('click', () => { - const rows = $$('.backend-server-row'); - if (rows.length > 1) row.remove(); - else alert('Musi istnieć co najmniej jeden backend.'); + 'use strict'; + + // ===== HELPER FUNCTIONS ===== + const $ = (sel, root = document) => root.querySelector(sel); + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + const toggle = (on, el) => el && el.classList.toggle('d-none', !on); + + // ===== SSL FIELDS ===== + const sslCheckbox = $('#ssl_checkbox'); + const sslFields = $('#ssl_fields'); + sslCheckbox?.addEventListener('change', () => toggle(sslCheckbox.checked, sslFields)); + + // ===== DOS PROTECTION ===== + const dosCheckbox = $('#add_dos'); + const dosFields = $('#dos_fields'); + dosCheckbox?.addEventListener('change', () => toggle(dosCheckbox.checked, dosFields)); + + // ===== PROTOCOL CHANGE (HTTP/TCP) ===== + const protocolSelect = $('#protocol'); + const httpGroups = $$('.http-only, #forbidden_acl_container'); + const httpToggles = [ + $('#sql_injection_check'), + $('#xss_check'), + $('#remote_uploads_check'), + $('#webshells_check'), + $('#forward_for_check'), + $('#add_acl_path'), + $('#add_path_based'), + $('#add_custom_acl'), + ]; + + const forbiddenFields = $('#forbidden_fields'); + const pathFields = $('#base_redirect_fields'); + + const onProtocolChange = () => { + const isHttp = protocolSelect?.value === 'http'; + httpGroups.forEach(el => toggle(isHttp, el)); + + if (!isHttp) { + [forbiddenFields, pathFields].forEach(el => toggle(false, el)); + httpToggles.forEach(input => { + if (input) input.checked = false; + }); + } + }; + + protocolSelect?.addEventListener('change', onProtocolChange); + onProtocolChange(); + + // ===== BACKEND SSL REDIRECT ===== + const backendSslCheckbox = $('#backend_ssl_redirect'); + const backendSslFields = $('#backend_ssl_fields'); + + backendSslCheckbox?.addEventListener('change', function() { + toggle(this.checked, backendSslFields); }); - return row; - }; - addBtn?.addEventListener('click', () => container?.appendChild(createRow())); - - // auto dismiss alerts - setTimeout(() => $$('.alert').forEach(a => { - if (typeof bootstrap !== 'undefined') new bootstrap.Alert(a).close(); - }), 5000); + + // ===== CUSTOM ACL (Main Toggle) ===== + const customAclCheckbox = $('#add_custom_acl'); + const customAclFields = $('#custom_acl_fields'); + + customAclCheckbox?.addEventListener('change', function() { + toggle(this.checked, customAclFields); + }); + + // ===== CUSTOM ACL Action Type Toggle ===== + const customAclAction = $('#custom_acl_action'); + const aclBackendSelect = $('#acl_backend_select'); + const aclRedirectSelect = $('#acl_redirect_select'); + + const onCustomAclActionChange = () => { + const action = customAclAction?.value; + toggle(action === 'route', aclBackendSelect); + toggle(action === 'redirect', aclRedirectSelect); + }; + + customAclAction?.addEventListener('change', onCustomAclActionChange); + // Initial state + onCustomAclActionChange(); + })(); diff --git a/static/js/logs.js b/static/js/logs.js index e69de29..d0d790f 100644 --- a/static/js/logs.js +++ b/static/js/logs.js @@ -0,0 +1,103 @@ +document.addEventListener('DOMContentLoaded', function() { + const filterIp = document.getElementById('filter_ip'); + const filterStatus = document.getElementById('filter_status'); + const filterMethod = document.getElementById('filter_method'); + const filterThreats = document.getElementById('filter_threats'); + const filterHideStats = document.getElementById('filter_hide_stats'); + const resetBtn = document.getElementById('reset_filters'); + + const logsTable = document.getElementById('logs_table'); + if (!logsTable) return; // Exit if no logs + + const allRows = Array.from(document.querySelectorAll('.log-row')); + + // Filter function + function applyFilters() { + const ipValue = filterIp.value.toLowerCase(); + const statusValue = filterStatus.value; + const methodValue = filterMethod.value; + const showThreats = filterThreats.checked; + const hideStats = filterHideStats.checked; + + let visibleCount = 0; + let threatCount = 0; + let count2xx = 0, count4xx = 0, count5xx = 0; + const uniqueIps = new Set(); + + allRows.forEach(row => { + const ip = row.dataset.ip; + const status = row.dataset.status; + const method = row.dataset.method; + const hasThreat = row.dataset.threats === '1'; + const url = row.querySelector('td:nth-child(4)').textContent.trim(); + + let show = true; + + // IP filter + if (ipValue && !ip.includes(ipValue)) { + show = false; + } + + // Status filter + if (statusValue) { + const statusStart = statusValue; + if (!status.startsWith(statusStart)) { + show = false; + } + } + + // Method filter + if (methodValue && method !== methodValue) { + show = false; + } + + // Threats filter + if (!showThreats && hasThreat) { + show = false; + } + + // Hide /stats filter + if (hideStats && url.includes('/stats')) { + show = false; + } + + row.style.display = show ? '' : 'none'; + + if (show) { + visibleCount++; + if (hasThreat) threatCount++; + if (status.startsWith('2')) count2xx++; + if (status.startsWith('4')) count4xx++; + if (status.startsWith('5')) count5xx++; + uniqueIps.add(ip); + } + }); + + // Update stats + document.getElementById('stat_total').textContent = visibleCount; + document.getElementById('stat_threats').textContent = threatCount; + document.getElementById('stat_2xx').textContent = count2xx; + document.getElementById('stat_4xx').textContent = count4xx; + document.getElementById('stat_5xx').textContent = count5xx; + document.getElementById('stat_ips').textContent = uniqueIps.size; + } + + // Event listeners + filterIp.addEventListener('input', applyFilters); + filterStatus.addEventListener('change', applyFilters); + filterMethod.addEventListener('change', applyFilters); + filterThreats.addEventListener('change', applyFilters); + filterHideStats.addEventListener('change', applyFilters); + + // Reset button + resetBtn.addEventListener('click', function() { + filterIp.value = ''; + filterStatus.value = ''; + filterMethod.value = ''; + filterThreats.checked = true; + filterHideStats.checked = true; + applyFilters(); + }); + + applyFilters(); +}); diff --git a/supervisord.conf b/supervisord.conf index 3ed80a0..64f265b 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -5,6 +5,16 @@ loglevel=info logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + [program:haproxy] command=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg autostart=true @@ -14,6 +24,8 @@ stdout_logfile=/var/log/haproxy.log priority=100 stopasgroup=true killasgroup=true +startsecs=10 +stopwaitsecs=10 [program:flask_app] command=python /app/app.py @@ -26,10 +38,3 @@ priority=999 environment=FLASK_APP=/app/app.py,FLASK_ENV=production,PYTHONUNBUFFERED=1 startsecs=10 stopasgroup=true - -[unix_http_server] -file=/var/run/supervisor.sock -chmod=0700 - -[supervisorctl] -serverurl=unix:///var/run/supervisor.sock diff --git a/templates/base.html b/templates/base.html index ed5c4b3..29ae675 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,7 @@ {% block title %}HAProxy Configurator{% endblock %} + {% block head %}{% endblock %} diff --git a/templates/edit.html b/templates/edit.html index 5f137ce..ef714a5 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -1,34 +1,83 @@ {% extends "base.html" %} -{% set active_page = "" %} -{% block title %}HAProxy • Edit{% endblock %} -{% block breadcrumb %}{% endblock %} -{% block content %} -
-
-

Edit HAProxy configuration

-
-
- - -
-
- - -
-
- {% if check_output %} - - {% endif %} -
+{% set active_page = "edit" %} + +{% block title %}HAProxy • Configuration Editor{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + + + + + +{% if check_output %} + -{% endblock %} -{% block page_js %} - +{% endif %} + + +
+
+
+
HAProxy Configuration Editor
+
+ Real-time editor with syntax highlighting +
+ +
+
+ +
+ + + +
+ + +
+
+ + + + Cancel + +
+ + + Line 1, Col 1 | + 0 characters + +
+
+
+
+ + + + + + + {% endblock %} diff --git a/templates/index.html b/templates/index.html index dcba33a..452f664 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,220 +1,467 @@ {% extends "base.html" %} + {% set active_page = "index" %} -{% block title %}HAProxy • Index{% endblock %} -{% block breadcrumb %}{% endblock %} + +{% block title %}HAProxy • Configuration{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + {% block content %} -
-
-
-
New frontend
- {% if message %} - - {% endif %} -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
-
- - -
-
-
- -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
- -
-
-
-
-
- - -
+
+
+
+
+
{{ frontend_count|default(0) }}
+

Frontends

-
-
- - -
+
+
+
+
+
+
{{ backend_count|default(0) }}
+

Backends

-
-
- - -
+
+
+
+
+
+
{{ acl_count|default(0) }}
+

ACLs

-
-
- - -
+
+
+
+
+
+
L7: {{ layer7_count|default(0) }} / L4: {{ layer4_count|default(0) }}
+

Layers

-
-
- - -
-
-
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- -
Backend pool
-
-
- - -
-
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
- -
+
-{% endblock %} -{% block page_js %} + +{% if message %} + +{% endif %} + +
+
+
+
Add New Configuration
+
+
+ + +
Frontend Configuration
+ +
+
+ + +
+
+ + +
+
+ + + Frontend name will be generated automatically +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ + + Full path to .pem file +
+
+
+ + +
+
+
+ + +
+
+
+ + + Creates additional frontend on port 80 +
+
+
+ +
+
+ + +
+
+ +
+ + +
Backend Configuration
+ +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ + +
+
+
+ +
+
+ +
+ + +
Headers & Security
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+
+ + +
+
+
+ + + Adds: http-response del-header Server +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
Protection
+ + +
+
+
+ + +
+
+
+ + + e.g. 30m, 1h, 24h +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
Custom ACL Rules (Advanced)
+ +
+
+
+ + + Create additional routing or blocking rules +
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+ + + {% endblock %} diff --git a/templates/logs.html b/templates/logs.html index 94f62b7..53c3f82 100644 --- a/templates/logs.html +++ b/templates/logs.html @@ -1,50 +1,211 @@ {% extends "base.html" %} -{% set active_page = "" %} + +{% set active_page = "logs" %} + {% block title %}HAProxy • Logs{% endblock %} -{% block breadcrumb %}{% endblock %} -{% block content %} -

Status 403 Forbidden

-{% if entries %} -
- {% for entry in entries %} -
-
-
-
-
Czas: {{ entry['timestamp'] }}
-
IP: {{ entry['ip_address'] }}
-
Metoda: {{ entry['http_method'] }}
-
URL: {{ entry['requested_url'] }}
-
Status: 403
-
-
- {% if entry['xss_alert'] %} -

-
{{ entry['xss_alert'] }}
- {% endif %} - {% if entry['sql_alert'] %} -

-
{{ entry['sql_alert'] }}
- {% endif %} - {% if entry['put_method'] %} -

-
{{ entry['put_method'] }}
- {% endif %} - {% if entry['illegal_resource'] %} -

-
{{ entry['illegal_resource'] }}
- {% endif %} - {% if entry['webshell_alert'] %} -

-
{{ entry['webshell_alert'] }}
- {% endif %} -
-
-
-
- {% endfor %} -
-{% else %} -
No data.
-{% endif %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + +
+
+
HAProxy Access Logs
+
+
+ + {% if error_message %} + + {% endif %} + + {% if logs and logs|length > 0 %} +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+
Total
+ {{ logs|length }} +
+
+
+
+
+
+
Threats
+ 0 +
+
+
+
+
+
+
2xx
+ 0 +
+
+
+
+
+
+
4xx
+ 0 +
+
+
+
+
+
+
5xx
+ 0 +
+
+
+
+
+
+
Unique IPs
+ 0 +
+
+
+
+ +
+ +
+ + + + + + + + + + + + + {% for entry in logs %} + + + + + + + + + {% endfor %} + +
TimestampIP AddressHTTP MethodRequested URLStatus CodeAlerts
{{ entry['timestamp'] }} + {{ entry['ip_address'] }} + + {{ entry['http_method'] }} + + {{ entry['requested_url'] }} + + + {{ entry['status_code'] }} + + + {% if entry['xss_alert'] %} + XSS + {% endif %} + {% if entry['sql_alert'] %} + SQL + {% endif %} + {% if entry['put_method'] %} + PUT + {% endif %} + {% if entry['webshell_alert'] %} + Webshell + {% endif %} + {% if entry['illegal_resource'] %} + 403 + {% endif %} +
+
+ + {% elif logs %} +
+ No log entries match your filters. +
+ {% else %} + + {% endif %} + +
+
+ + + {% endblock %} diff --git a/utils/haproxy_config.py b/utils/haproxy_config.py index a55174c..9b85215 100644 --- a/utils/haproxy_config.py +++ b/utils/haproxy_config.py @@ -2,36 +2,89 @@ import os HAPROXY_CFG = '/etc/haproxy/haproxy.cfg' -def is_frontend_exist(frontend_name, frontend_ip, frontend_port): - """Check if frontend with given name, IP and port already exists""" +def sanitize_name(name): + """Convert hostname/name to valid ACL name""" + return name.replace('.', '_').replace('-', '_').replace('/', '_').replace(':', '_') + +def frontend_exists_at_port(frontend_ip, frontend_port): + """Check if frontend already exists at specific port""" + if not os.path.exists(HAPROXY_CFG): + return None + + try: + with open(HAPROXY_CFG, 'r') as f: + content = f.read() + lines = content.split('\n') + + for i, line in enumerate(lines): + if line.strip().startswith('frontend'): + # Szukaj bind line + for j in range(i+1, min(i+10, len(lines))): + if lines[j].strip().startswith('bind'): + bind_info = lines[j].strip().split(' ', 1)[1] + if f"{frontend_ip}:{frontend_port}" in bind_info: + return line.strip().split(' ', 1)[1] # Zwróć nazwę frontendu + elif lines[j].strip().startswith('frontend') or lines[j].strip().startswith('backend'): + break + except Exception as e: + print(f"[HAPROXY_CONFIG] Error: {e}", flush=True) + + return None + +def add_acl_to_frontend(frontend_name, acl_name, hostname, backend_name): + """Dodaj ACL i use_backend do istniejącego frontendu""" if not os.path.exists(HAPROXY_CFG): return False - + try: - with open(HAPROXY_CFG, 'r') as haproxy_cfg: - frontend_found = False - for line in haproxy_cfg: - if line.strip().startswith('frontend'): - _, existing_frontend_name = line.strip().split(' ', 1) - if existing_frontend_name.strip() == frontend_name: - frontend_found = True - else: - frontend_found = False - elif frontend_found and line.strip().startswith('bind'): - _, bind_info = line.strip().split(' ', 1) - existing_ip, existing_port = bind_info.split(':', 1) - if existing_ip.strip() == frontend_ip and existing_port.strip() == frontend_port: - return True + with open(HAPROXY_CFG, 'r') as f: + lines = f.readlines() + + # Znajdź frontend + frontend_idx = -1 + for i, line in enumerate(lines): + if 'frontend' in line and frontend_name in line: + frontend_idx = i + break + + if frontend_idx == -1: + return False + + # Sprawdź czy ACL już istnieje + for line in lines[frontend_idx:]: + if acl_name in line and 'acl' in line: + return True # Już istnieje + if line.strip().startswith('backend'): + break + + # Znajdź ostatnią linię ACL/use_backend w tym frontendzie + insert_idx = frontend_idx + 1 + for i in range(frontend_idx + 1, len(lines)): + if lines[i].strip().startswith('backend'): + insert_idx = i + break + if 'use_backend' in lines[i] or 'default_backend' in lines[i]: + insert_idx = i + 1 + + # Wstaw ACL i use_backend + acl_line = f" acl {acl_name} hdr(host) -i {hostname}\n" + use_backend_line = f" use_backend {backend_name} if {acl_name}\n" + + lines.insert(insert_idx, use_backend_line) + lines.insert(insert_idx, acl_line) + + with open(HAPROXY_CFG, 'w') as f: + f.writelines(lines) + + return True except Exception as e: - print(f"[HAPROXY_CONFIG] Error checking frontend: {e}", flush=True) - - return False + print(f"[HAPROXY_CONFIG] Error adding ACL: {e}", flush=True) + return False def is_backend_exist(backend_name): - """Check if backend with given name already exists""" if not os.path.exists(HAPROXY_CFG): return False - + try: with open(HAPROXY_CFG, 'r') as haproxy_cfg: for line in haproxy_cfg: @@ -42,25 +95,24 @@ def is_backend_exist(backend_name): return True except Exception as e: print(f"[HAPROXY_CONFIG] Error checking backend: {e}", flush=True) - + return False def count_frontends_and_backends(): - """Count frontends, backends, ACLs and layer types""" if not os.path.exists(HAPROXY_CFG): return 0, 0, 0, 0, 0 - + frontend_count = 0 backend_count = 0 acl_count = 0 layer7_count = 0 layer4_count = 0 - + try: with open(HAPROXY_CFG, 'r') as haproxy_cfg: content = haproxy_cfg.read() lines = content.split('\n') - + for line in lines: line_stripped = line.strip() if line_stripped.startswith('frontend'): @@ -75,100 +127,223 @@ def count_frontends_and_backends(): acl_count += 1 except Exception as e: print(f"[HAPROXY_CONFIG] Error counting: {e}", flush=True) - + return frontend_count, backend_count, acl_count, layer7_count, layer4_count -def 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): - - # Ensure directory exists +def 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, + del_server_header=False, backend_ssl_redirect=False, ssl_redirect_backend_name='', + ssl_redirect_port='80', frontend_hostname='', add_custom_acl=False, + custom_acl_name='', custom_acl_type='path_beg', custom_acl_value='', + custom_acl_action='route', custom_acl_backend='', custom_acl_redirect_url=''): + os.makedirs(os.path.dirname(HAPROXY_CFG), exist_ok=True) - - if is_backend_exist(backend_name): - return f"Backend {backend_name} already exists. Cannot add duplicate." - + + unique_backend_name = f"{backend_name}_{sanitize_name(frontend_hostname)}" if frontend_hostname else backend_name + + if is_backend_exist(unique_backend_name): + return f"Backend {unique_backend_name} already exists. Cannot add duplicate." + + is_no_lb = lb_method == 'no-lb' + if is_no_lb and len(backend_servers) > 1: + backend_servers = backend_servers[:1] + try: + # ===== CHECK IF FRONTEND EXISTS AT PORT ===== + existing_frontend = frontend_exists_at_port(frontend_ip, frontend_port) + + if existing_frontend: + # Frontend już istnieje - dodaj tylko backend + ACL + print(f"[HAPROXY] Found existing frontend '{existing_frontend}' at {frontend_ip}:{frontend_port}", flush=True) + + with open(HAPROXY_CFG, 'a') as haproxy_cfg: + # ===== BACKEND ===== + haproxy_cfg.write(f"\nbackend {unique_backend_name}\n") + + if not is_no_lb: + haproxy_cfg.write(f" balance {lb_method}\n") + + if sticky_session and not is_no_lb: + if sticky_session_type == "cookie": + haproxy_cfg.write(f" cookie SERVERID insert indirect nocache\n") + elif sticky_session_type == "source": + haproxy_cfg.write(f" stick-table type ip size 200k expire 30m\n") + haproxy_cfg.write(f" stick on src\n") + + if health_check and protocol == 'http': + haproxy_cfg.write(f" option httpchk GET {health_check_link}\n") + elif health_check_tcp and protocol == 'tcp': + haproxy_cfg.write(f" option tcp-check\n") + + if add_header: + haproxy_cfg.write(f" http-response add-header {header_name} {header_value}\n") + + if del_server_header: + haproxy_cfg.write(f" http-response del-header Server\n") + + if forward_for: + haproxy_cfg.write(f" option forwardfor\n") + + # Add servers + for server_name, server_ip, server_port, maxconn in backend_servers: + maxconn_str = f" maxconn {maxconn}" if maxconn else "" + + if health_check and protocol == 'http': + haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str} check\n") + else: + haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str}\n") + + # Dodaj ACL do istniejącego frontendu + acl_name_sanitized = f"is_{sanitize_name(frontend_hostname)}" if frontend_hostname else f"is_{unique_backend_name}" + add_acl_to_frontend(existing_frontend, acl_name_sanitized, frontend_hostname or 'localhost', unique_backend_name) + + return f"Backend added to existing frontend" + + # ===== TWORZENIE NOWEGO FRONTENDU (GENERYCZNE NAZWY) ===== + # Generuj generyczną nazwę frontendu + generic_frontend_name = f"https_frontend" if use_ssl else f"http_frontend" + generic_http_redirect_name = f"http_redirect_frontend" + + print(f"[HAPROXY] Creating new frontend '{generic_frontend_name}' at {frontend_ip}:{frontend_port}", flush=True) + with open(HAPROXY_CFG, 'a') as haproxy_cfg: - haproxy_cfg.write(f"\nfrontend {frontend_name}\n") - - if is_frontend_exist(frontend_name, frontend_ip, frontend_port): - return "Frontend or Port already exists. Cannot add duplicate." - + # ===== PRIMARY FRONTEND (GENERIC NAME) ===== + haproxy_cfg.write(f"\nfrontend {generic_frontend_name}\n") haproxy_cfg.write(f" bind {frontend_ip}:{frontend_port}") - + if use_ssl: haproxy_cfg.write(f" ssl crt {ssl_cert_path}") + haproxy_cfg.write("\n") - - if https_redirect: - haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n") - - if forward_for: - haproxy_cfg.write(f" option forwardfor\n") - + + # Headers zaraz po BIND/CERT + haproxy_cfg.write(f" http-request set-header X-Forwarded-For %[src]\n") + if use_ssl: + haproxy_cfg.write(f" http-request set-header X-Forwarded-Proto https\n") + else: + haproxy_cfg.write(f" http-request set-header X-Forwarded-Proto http\n") + haproxy_cfg.write(f" mode {protocol}\n") - haproxy_cfg.write(f" balance {lb_method}\n") - - # Add protection rules + + # ACL dla pierwszego vhost + acl_name_sanitized = None + if frontend_hostname: + acl_name_sanitized = f"is_{sanitize_name(frontend_hostname)}" + haproxy_cfg.write(f" acl {acl_name_sanitized} hdr(host) -i {frontend_hostname}\n") + + if not is_no_lb: + haproxy_cfg.write(f" balance {lb_method}\n") + if forward_for: + haproxy_cfg.write(f" option forwardfor\n") + + # Protections if is_dos: haproxy_cfg.write(f" stick-table type ip size 1m expire {ban_duration} store http_req_rate(1m)\n") haproxy_cfg.write(f" http-request track-sc0 src\n") haproxy_cfg.write(f" acl abuse sc_http_req_rate(0) gt {limit_requests}\n") haproxy_cfg.write(f" http-request silent-drop if abuse\n") - + if sql_injection_check: - # POPRAWNE escape sequence'i - podwójny backslash dla haproxy haproxy_cfg.write(" acl is_sql_injection urlp_reg -i (union|select|insert|update|delete|drop|@@|1=1|`1)\n") haproxy_cfg.write(" acl is_long_uri path_len gt 400\n") haproxy_cfg.write(" acl semicolon_path path_reg -i ^.*;.*\n") haproxy_cfg.write(" acl is_sql_injection2 urlp_reg -i (;|substring|extract|union\\s+all|order\\s+by)\\s+(\\d+|--\\+)\n") haproxy_cfg.write(f" http-request deny if is_sql_injection or is_long_uri or semicolon_path or is_sql_injection2\n") - + if is_xss: haproxy_cfg.write(" acl is_xss_attack urlp_reg -i (<|>|script|alert|onerror|onload|javascript)\n") haproxy_cfg.write(" acl is_xss_attack_2 urlp_reg -i (<\\s*script\\s*|javascript:|<\\s*img\\s*src\\s*=|<\\s*a\\s*href\\s*=|<\\s*iframe\\s*src\\s*=|\\bon\\w+\\s*=|<\\s*input\\s*[^>]*\\s*value\\s*=|<\\s*form\\s*action\\s*=|<\\s*svg\\s*on\\w+\\s*=)\n") haproxy_cfg.write(" acl is_xss_attack_hdr hdr_reg(Cookie|Referer|User-Agent) -i (<|>|script|alert|onerror|onload|javascript)\n") haproxy_cfg.write(f" http-request deny if is_xss_attack or is_xss_attack_2 or is_xss_attack_hdr\n") - + if is_webshells: haproxy_cfg.write(" acl blocked_webshell path_reg -i /(cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil).*\\.php.*\n") haproxy_cfg.write(f" http-request deny if blocked_webshell\n") - - haproxy_cfg.write(f" default_backend {backend_name}\n") - - # Backend section - haproxy_cfg.write(f"\nbackend {backend_name}\n") - haproxy_cfg.write(f" balance {lb_method}\n") - - if sticky_session: + + if https_redirect: + haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n") + + if del_server_header: + haproxy_cfg.write(f" http-response del-header Server\n") + + # Backend routing + if acl_name_sanitized: + haproxy_cfg.write(f" use_backend {unique_backend_name} if {acl_name_sanitized}\n") + else: + haproxy_cfg.write(f" default_backend {unique_backend_name}\n") + + # ===== BACKEND ===== + haproxy_cfg.write(f"\nbackend {unique_backend_name}\n") + + if not is_no_lb: + haproxy_cfg.write(f" balance {lb_method}\n") + + if sticky_session and not is_no_lb: if sticky_session_type == "cookie": haproxy_cfg.write(f" cookie SERVERID insert indirect nocache\n") elif sticky_session_type == "source": haproxy_cfg.write(f" stick-table type ip size 200k expire 30m\n") haproxy_cfg.write(f" stick on src\n") - + if health_check and protocol == 'http': haproxy_cfg.write(f" option httpchk GET {health_check_link}\n") elif health_check_tcp and protocol == 'tcp': haproxy_cfg.write(f" option tcp-check\n") - + if add_header: haproxy_cfg.write(f" http-response add-header {header_name} {header_value}\n") - - # Add backend servers + + if del_server_header: + haproxy_cfg.write(f" http-response del-header Server\n") + + if forward_for: + haproxy_cfg.write(f" option forwardfor\n") + for server_name, server_ip, server_port, maxconn in backend_servers: maxconn_str = f" maxconn {maxconn}" if maxconn else "" + if health_check and protocol == 'http': haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str} check\n") else: haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str}\n") - - return "Configuration updated successfully!" + + # ===== REDIRECT HTTP -> HTTPS (GENERIC NAME) ===== + if backend_ssl_redirect and ssl_redirect_backend_name: + unique_redirect_backend_name = f"{ssl_redirect_backend_name}_redirect_{sanitize_name(frontend_hostname)}" if frontend_hostname else ssl_redirect_backend_name + + # Check if HTTP redirect frontend exists + existing_http_frontend = frontend_exists_at_port(frontend_ip, ssl_redirect_port) + + if not existing_http_frontend: + # Utwórz nowy HTTP redirect frontend (generic name) + haproxy_cfg.write(f"\nfrontend {generic_http_redirect_name}\n") + haproxy_cfg.write(f" bind {frontend_ip}:{ssl_redirect_port}\n") + haproxy_cfg.write(f" mode http\n") + + if frontend_hostname: + acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect" + haproxy_cfg.write(f" acl {acl_name_redirect} hdr(host) -i {frontend_hostname}\n") + haproxy_cfg.write(f" use_backend {unique_redirect_backend_name} if {acl_name_redirect}\n") + else: + haproxy_cfg.write(f" default_backend {unique_redirect_backend_name}\n") + else: + # Dodaj ACL do istniejącego HTTP frontendu + if frontend_hostname: + acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect" + add_acl_to_frontend(existing_http_frontend, acl_name_redirect, frontend_hostname, unique_redirect_backend_name) + + # Redirect backend + haproxy_cfg.write(f"\nbackend {unique_redirect_backend_name}\n") + haproxy_cfg.write(f" mode http\n") + haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n") + + return "Configuration updated successfully!" + except Exception as e: print(f"[HAPROXY_CONFIG] Error updating config: {e}", flush=True) return f"Error: {e}" diff --git a/utils/stats_utils.py b/utils/stats_utils.py index 4d2ddb6..bd0a5df 100644 --- a/utils/stats_utils.py +++ b/utils/stats_utils.py @@ -1,7 +1,7 @@ import requests import csv -HAPROXY_STATS_URL = 'http://127.0.0.1:8484/;csv' +HAPROXY_STATS_URL = 'http://127.0.0.1:8404/stats;csv' def fetch_haproxy_stats(): try: @@ -13,18 +13,58 @@ def fetch_haproxy_stats(): def parse_haproxy_stats(stats_data): data = [] - header_row = stats_data.splitlines()[0].replace('# ', '') - reader = csv.DictReader(stats_data.splitlines(), fieldnames=header_row.split(',')) + + lines = [line for line in stats_data.splitlines() if line.strip()] + if not lines: + return data + + header_row = lines[0].replace('# ', '') + + # Parse CSV + reader = csv.DictReader(lines, fieldnames=header_row.split(',')) next(reader) + for row in reader: - if row['svname'] != 'BACKEND': - data.append({ - 'frontend_name': row['pxname'], - 'server_name': row['svname'], - '4xx_errors': row['hrsp_4xx'], - '5xx_errors': row['hrsp_5xx'], - 'bytes_in_mb': f'{float(row["bin"]) / (1024 * 1024):.2f}', - 'bytes_out_mb': f'{float(row["bout"]) / (1024 * 1024):.2f}', - 'conn_tot': row['conn_tot'], - }) - return data \ No newline at end of file + if row.get('svname') == 'BACKEND': + continue + + row = {k: v.strip() if isinstance(v, str) else v for k, v in row.items()} + + try: + conn_tot = int(row.get('conn_tot', 0) or 0) + except (ValueError, TypeError): + conn_tot = 0 + + try: + hrsp_4xx = int(row.get('hrsp_4xx', 0) or 0) + except (ValueError, TypeError): + hrsp_4xx = 0 + + try: + hrsp_5xx = int(row.get('hrsp_5xx', 0) or 0) + except (ValueError, TypeError): + hrsp_5xx = 0 + + try: + bin_bytes = float(row.get('bin', 0) or 0) + bytes_in_mb = bin_bytes / (1024 * 1024) + except (ValueError, TypeError): + bytes_in_mb = 0.0 + + try: + bout_bytes = float(row.get('bout', 0) or 0) + bytes_out_mb = bout_bytes / (1024 * 1024) + except (ValueError, TypeError): + bytes_out_mb = 0.0 + + data.append({ + 'frontend_name': row.get('pxname', 'Unknown'), + 'server_name': row.get('svname', 'Unknown'), + '4xx_errors': hrsp_4xx, + '5xx_errors': hrsp_5xx, + 'bytes_in_mb': bytes_in_mb, + 'bytes_out_mb': bytes_out_mb, + 'conn_tot': conn_tot, + }) + + return data