import sqlite3 import os from flask import Flask, request, jsonify, render_template, send_file from pathlib import Path import subprocess import jinja2 import socket import psutil import functools import logging from datetime import datetime, timedelta import json import ipaddress import shutil import tempfile app = Flask(__name__) # Configuration DB_FILE = '/etc/haproxy/haproxy_config.db' TEMPLATE_DIR = Path('templates') HAPROXY_CONFIG_PATH = '/etc/haproxy/haproxy.cfg' HAPROXY_BACKUP_PATH = '/etc/haproxy/haproxy.cfg.backup' BLOCKED_IPS_MAP_PATH = '/etc/haproxy/blocked_ips.map' BLOCKED_IPS_MAP_BACKUP_PATH = '/etc/haproxy/blocked_ips.map.backup' HAPROXY_SOCKET_PATH = '/var/run/haproxy.sock' SSL_CERTS_DIR = '/etc/haproxy/certs' API_KEY = os.environ.get('HAPROXY_API_KEY') # Optional API key for authentication # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('/var/log/haproxy-manager.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) def require_api_key(f): """Decorator to require API key authentication if API_KEY is set""" @functools.wraps(f) def decorated_function(*args, **kwargs): if API_KEY: auth_header = request.headers.get('Authorization') if not auth_header or auth_header != f'Bearer {API_KEY}': return jsonify({'error': 'Unauthorized - Invalid or missing API key'}), 401 return f(*args, **kwargs) return decorated_function def log_operation(operation, success=True, error_message=None): """Log operations for monitoring and alerting""" log_entry = { 'timestamp': datetime.now().isoformat(), 'operation': operation, 'success': success, 'error': error_message } if success: logger.info(f"Operation {operation} completed successfully") else: logger.error(f"Operation {operation} failed: {error_message}") # Here you could add additional alerting (email, webhook, etc.) # For now, we'll just log to a dedicated error log with open('/var/log/haproxy-manager-errors.log', 'a') as f: f.write(json.dumps(log_entry) + '\n') return log_entry def init_db(): with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Create domains table cursor.execute(''' CREATE TABLE IF NOT EXISTS domains ( id INTEGER PRIMARY KEY, domain TEXT UNIQUE NOT NULL, ssl_enabled BOOLEAN DEFAULT 0, ssl_cert_path TEXT, template_override TEXT ) ''') # Create backends table cursor.execute(''' CREATE TABLE IF NOT EXISTS backends ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, domain_id INTEGER, settings TEXT, FOREIGN KEY (domain_id) REFERENCES domains (id) ) ''') # Create backend_servers table cursor.execute(''' CREATE TABLE IF NOT EXISTS backend_servers ( id INTEGER PRIMARY KEY, backend_id INTEGER, server_name TEXT NOT NULL, server_address TEXT NOT NULL, server_port INTEGER NOT NULL, server_options TEXT, FOREIGN KEY (backend_id) REFERENCES backends (id) ) ''') # Create blocked_ips table cursor.execute(''' CREATE TABLE IF NOT EXISTS blocked_ips ( id INTEGER PRIMARY KEY, ip_address TEXT UNIQUE NOT NULL, reason TEXT, blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, blocked_by TEXT ) ''') conn.commit() def validate_ip_address(ip_string): """Validate if a string is a valid IP address""" try: ipaddress.ip_address(ip_string) return True except ValueError: return False def certbot_register(): """Register with Let's Encrypt using the certbot client and agree to the terms of service""" result = subprocess.run(['certbot', 'show_account'], capture_output=True) if result.returncode != 0: subprocess.run(['certbot', 'register', '--agree-tos', '--register-unsafely-without-email', '--no-eff-email']) def generate_self_signed_cert(ssl_certs_dir): """Generate a self-signed certificate for a domain.""" self_sign_cert = os.path.join(ssl_certs_dir, "default_self_signed_cert.pem") print(self_sign_cert) if os.path.exists(self_sign_cert): print("Self Signed Cert Found") return True try: os.mkdir(ssl_certs_dir) except FileExistsError: pass DOMAIN = socket.gethostname() # Generate private key and certificate subprocess.run([ 'openssl', 'req', '-x509', '-newkey', 'rsa:4096', '-keyout', '/tmp/key.pem', '-out', '/tmp/cert.pem', '-days', '3650', '-nodes', # No passphrase '-subj', f'/CN={DOMAIN}' ], check=True) # Combine cert and key for HAProxy with open(self_sign_cert, 'wb') as combined: for file in ['/tmp/cert.pem', '/tmp/key.pem']: with open(file, 'rb') as f: combined.write(f.read()) os.remove(file) # Clean up temporary files generate_config() return True def is_process_running(process_name): for process in psutil.process_iter(['name']): if process.info['name'] == process_name: return True return False # Initialize template engine template_loader = jinja2.FileSystemLoader(TEMPLATE_DIR) template_env = jinja2.Environment(loader=template_loader) @app.route('/api/domains', methods=['GET']) @require_api_key def get_domains(): try: with sqlite3.connect(DB_FILE) as conn: conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(''' SELECT d.*, b.name as backend_name FROM domains d LEFT JOIN backends b ON d.id = b.domain_id ''') domains = [dict(row) for row in cursor.fetchall()] log_operation('get_domains', True) return jsonify(domains) except Exception as e: log_operation('get_domains', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/health', methods=['GET']) def health_check(): try: # Check if HAProxy is running haproxy_running = is_process_running('haproxy') # Check if database is accessible with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT 1') cursor.fetchone() return jsonify({ 'status': 'healthy', 'haproxy_status': 'running' if haproxy_running else 'stopped', 'database': 'connected' }), 200 except Exception as e: return jsonify({ 'status': 'unhealthy', 'error': str(e) }), 500 @app.route('/api/regenerate', methods=['GET']) @require_api_key def regenerate_conf(): try: generate_config() log_operation('regenerate_config', True) return jsonify({'status': 'success'}), 200 except Exception as e: log_operation('regenerate_config', False, str(e)) return jsonify({ 'status': 'failed', 'error': str(e) }), 500 @app.route('/api/reload', methods=['GET']) @require_api_key def reload_haproxy(): try: if is_process_running('haproxy'): # Use a proper shell command string when shell=True is set result = subprocess.run('echo "reload" | socat stdio /tmp/haproxy-cli', check=True, capture_output=True, text=True, shell=True) print(f"Reload result: {result.stdout}, {result.stderr}, {result.returncode}") log_operation('reload_haproxy', True) return jsonify({'status': 'success'}), 200 else: # Start HAProxy if it's not running result = subprocess.run( ['haproxy', '-W', '-S', '/tmp/haproxy-cli,level,admin', '-f', HAPROXY_CONFIG_PATH], check=True, capture_output=True, text=True ) if result.returncode == 0: print("HAProxy started successfully") log_operation('start_haproxy', True) return jsonify({'status': 'success'}), 200 else: error_msg = f"HAProxy start command returned: {result.stdout}\nError output: {result.stderr}" print(error_msg) log_operation('start_haproxy', False, error_msg) return jsonify({'status': 'failed', 'error': error_msg}), 500 except subprocess.CalledProcessError as e: error_msg = f"Failed to start HAProxy: {e.stdout}\n{e.stderr}" print(error_msg) log_operation('reload_haproxy', False, error_msg) return jsonify({'status': 'failed', 'error': error_msg}), 500 @app.route('/api/domain', methods=['POST']) @require_api_key def add_domain(): data = request.get_json() domain = data.get('domain') template_override = data.get('template_override') backend_name = data.get('backend_name') servers = data.get('servers', []) if not domain or not backend_name: log_operation('add_domain', False, 'Domain and backend_name are required') return jsonify({'status': 'error', 'message': 'Domain and backend_name are required'}), 400 try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Add domain cursor.execute('INSERT INTO domains (domain, template_override) VALUES (?, ?)', (domain, template_override)) domain_id = cursor.lastrowid # Add backend cursor.execute('INSERT INTO backends (name, domain_id) VALUES (?, ?)', (backend_name, domain_id)) backend_id = cursor.lastrowid for server in servers: cursor.execute(''' INSERT INTO backend_servers (backend_id, server_name, server_address, server_port, server_options) VALUES (?, ?, ?, ?, ?) ''', (backend_id, server['name'], server['address'], server['port'], server.get('options'))) # Close cursor and connection cursor.close() conn.close() generate_config() log_operation('add_domain', True, f'Domain {domain} added successfully') return jsonify({'status': 'success', 'domain_id': domain_id}) except Exception as e: log_operation('add_domain', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/') def index(): return render_template('index.html') @app.route('/default-page') def default_page(): """Serve the default page for unmatched domains""" admin_email = os.environ.get('HAPROXY_ADMIN_EMAIL', 'admin@example.com') return render_template('default_page.html', page_title=os.environ.get('HAPROXY_DEFAULT_PAGE_TITLE', 'Site Not Configured'), main_message=os.environ.get('HAPROXY_DEFAULT_MAIN_MESSAGE', 'This domain has not been configured yet. Please contact your system administrator to set up this website.'), secondary_message=os.environ.get('HAPROXY_DEFAULT_SECONDARY_MESSAGE', 'If you believe this is an error, please check the domain name and try again.') ) @app.route('/api/ssl', methods=['POST']) @require_api_key def request_ssl(): """Legacy endpoint for requesting SSL certificate for a single domain""" data = request.get_json() domain = data.get('domain') if not domain: log_operation('request_ssl', False, 'Domain not provided') return jsonify({'status': 'error', 'message': 'Domain is required'}), 400 try: # Request Let's Encrypt certificate result = subprocess.run([ 'certbot', 'certonly', '-n', '--standalone', '--preferred-challenges', 'http', '--http-01-port=8688', '-d', domain ], capture_output=True, text=True) if result.returncode == 0: # Combine cert files and copy to HAProxy certs directory cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' # Ensure SSL certs directory exists os.makedirs(SSL_CERTS_DIR, exist_ok=True) with open(combined_path, 'w') as combined: subprocess.run(['cat', cert_path, key_path], stdout=combined) # Update database with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute(''' UPDATE domains SET ssl_enabled = 1, ssl_cert_path = ? WHERE domain = ? ''', (combined_path, domain)) # Close cursor and connection cursor.close() conn.close() generate_config() log_operation('request_ssl', True, f'SSL certificate obtained for {domain}') return jsonify({ 'status': 'success', 'domain': domain, 'cert_path': combined_path, 'message': 'Certificate obtained successfully' }) else: error_msg = f'Failed to obtain SSL certificate: {result.stderr}' log_operation('request_ssl', False, error_msg) return jsonify({'status': 'error', 'message': error_msg}), 500 except Exception as e: log_operation('request_ssl', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates/renew', methods=['POST']) @require_api_key def renew_certificates(): """Renew all certificates and reload HAProxy""" try: # Run certbot renew result = subprocess.run([ 'certbot', 'renew', '--quiet' ], capture_output=True, text=True) if result.returncode == 0: # Check if any certificates were renewed if 'Congratulations' in result.stdout or 'renewed' in result.stdout: # Update combined certificates for HAProxy with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT domain, ssl_cert_path FROM domains WHERE ssl_enabled = 1') domains = cursor.fetchall() for domain, cert_path in domains: if cert_path and os.path.exists(cert_path): # Recreate combined certificate letsencrypt_cert = f'/etc/letsencrypt/live/{domain}/fullchain.pem' letsencrypt_key = f'/etc/letsencrypt/live/{domain}/privkey.pem' if os.path.exists(letsencrypt_cert) and os.path.exists(letsencrypt_key): with open(cert_path, 'w') as combined: subprocess.run(['cat', letsencrypt_cert, letsencrypt_key], stdout=combined) # Regenerate config and reload HAProxy generate_config() reload_result = subprocess.run('echo "reload" | socat stdio /tmp/haproxy-cli', capture_output=True, text=True, shell=True) if reload_result.returncode == 0: log_operation('renew_certificates', True, 'Certificates renewed and HAProxy reloaded') return jsonify({'status': 'success', 'message': 'Certificates renewed and HAProxy reloaded'}) else: error_msg = f'Certificates renewed but HAProxy reload failed: {reload_result.stderr}' log_operation('renew_certificates', False, error_msg) return jsonify({'status': 'partial_success', 'message': error_msg}), 500 else: log_operation('renew_certificates', True, 'No certificates needed renewal') return jsonify({'status': 'success', 'message': 'No certificates needed renewal'}) else: error_msg = f'Certificate renewal failed: {result.stderr}' log_operation('renew_certificates', False, error_msg) return jsonify({'status': 'error', 'message': error_msg}), 500 except Exception as e: log_operation('renew_certificates', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates//download', methods=['GET']) @require_api_key def download_certificate(domain): """Download the combined certificate file for a domain""" try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT ssl_cert_path FROM domains WHERE domain = ? AND ssl_enabled = 1', (domain,)) result = cursor.fetchone() if not result or not result[0]: return jsonify({'status': 'error', 'message': 'Certificate not found for domain'}), 404 cert_path = result[0] if not os.path.exists(cert_path): return jsonify({'status': 'error', 'message': 'Certificate file not found'}), 404 log_operation('download_certificate', True, f'Certificate downloaded for {domain}') return send_file(cert_path, as_attachment=True, download_name=f'{domain}.pem') except Exception as e: log_operation('download_certificate', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates//key', methods=['GET']) @require_api_key def download_private_key(domain): """Download the private key for a domain""" try: key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' if not os.path.exists(key_path): return jsonify({'status': 'error', 'message': 'Private key not found for domain'}), 404 log_operation('download_private_key', True, f'Private key downloaded for {domain}') return send_file(key_path, as_attachment=True, download_name=f'{domain}_key.pem') except Exception as e: log_operation('download_private_key', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates//cert', methods=['GET']) @require_api_key def download_cert_only(domain): """Download only the certificate (without private key) for a domain""" try: cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' if not os.path.exists(cert_path): return jsonify({'status': 'error', 'message': 'Certificate not found for domain'}), 404 log_operation('download_cert_only', True, f'Certificate (only) downloaded for {domain}') return send_file(cert_path, as_attachment=True, download_name=f'{domain}_cert.pem') except Exception as e: log_operation('download_cert_only', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates/status', methods=['GET']) @require_api_key def get_certificate_status(): """Get status of all certificates including expiration dates""" try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT domain, ssl_enabled, ssl_cert_path FROM domains WHERE ssl_enabled = 1') domains = cursor.fetchall() cert_status = [] for domain, ssl_enabled, cert_path in domains: status = { 'domain': domain, 'ssl_enabled': bool(ssl_enabled), 'cert_path': cert_path, 'expires': None, 'days_until_expiry': None } if cert_path and os.path.exists(cert_path): # Check certificate expiration using openssl try: result = subprocess.run([ 'openssl', 'x509', '-in', cert_path, '-noout', '-dates' ], capture_output=True, text=True) if result.returncode == 0: # Parse the notAfter date for line in result.stdout.split('\n'): if 'notAfter=' in line: expiry_date_str = line.split('=')[1].strip() from datetime import datetime expiry_date = datetime.strptime(expiry_date_str, '%b %d %H:%M:%S %Y %Z') status['expires'] = expiry_date.isoformat() # Calculate days until expiry days_until = (expiry_date - datetime.now()).days status['days_until_expiry'] = days_until break except Exception as e: status['error'] = str(e) cert_status.append(status) log_operation('get_certificate_status', True) return jsonify({'certificates': cert_status}) except Exception as e: log_operation('get_certificate_status', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/certificates/request', methods=['POST']) @require_api_key def request_certificates(): """Request certificate generation for one or more domains""" data = request.get_json() domains = data.get('domains', []) force_renewal = data.get('force_renewal', False) include_www = data.get('include_www', True) if not domains: log_operation('request_certificates', False, 'No domains provided') return jsonify({'status': 'error', 'message': 'At least one domain is required'}), 400 if not isinstance(domains, list): domains = [domains] # Convert single domain to list results = [] success_count = 0 error_count = 0 for domain in domains: try: # Prepare domain list for certbot (include www subdomain if requested) certbot_domains = [domain] if include_www and not domain.startswith('www.'): certbot_domains.append(f'www.{domain}') # Build certbot command cmd = [ 'certbot', 'certonly', '-n', '--standalone', '--preferred-challenges', 'http', '--http-01-port=8688' ] if force_renewal: cmd.append('--force-renewal') # Add domains for d in certbot_domains: cmd.extend(['-d', d]) # Request certificate result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: # Combine cert files and copy to HAProxy certs directory cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' # Ensure SSL certs directory exists os.makedirs(SSL_CERTS_DIR, exist_ok=True) with open(combined_path, 'w') as combined: subprocess.run(['cat', cert_path, key_path], stdout=combined) # Update database (add domain if it doesn't exist) with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Check if domain exists cursor.execute('SELECT id FROM domains WHERE domain = ?', (domain,)) domain_exists = cursor.fetchone() if domain_exists: # Update existing domain cursor.execute(''' UPDATE domains SET ssl_enabled = 1, ssl_cert_path = ? WHERE domain = ? ''', (combined_path, domain)) else: # Add new domain with SSL enabled cursor.execute(''' INSERT INTO domains (domain, ssl_enabled, ssl_cert_path) VALUES (?, 1, ?) ''', (domain, combined_path)) results.append({ 'domain': domain, 'status': 'success', 'message': 'Certificate obtained successfully', 'cert_path': combined_path, 'domains_covered': certbot_domains }) success_count += 1 else: error_msg = f'Failed to obtain certificate for {domain}: {result.stderr}' results.append({ 'domain': domain, 'status': 'error', 'message': error_msg, 'stderr': result.stderr }) error_count += 1 except Exception as e: error_msg = f'Exception while processing {domain}: {str(e)}' results.append({ 'domain': domain, 'status': 'error', 'message': error_msg }) error_count += 1 # Regenerate HAProxy config if any certificates were successful if success_count > 0: try: generate_config() log_operation('request_certificates', True, f'Successfully obtained {success_count} certificates, {error_count} failed') except Exception as e: log_operation('request_certificates', False, f'Certificates obtained but config generation failed: {str(e)}') # Return results response = { 'status': 'completed', 'summary': { 'total': len(domains), 'successful': success_count, 'failed': error_count }, 'results': results } if error_count == 0: return jsonify(response), 200 elif success_count > 0: return jsonify(response), 207 # Multi-status (some succeeded, some failed) else: return jsonify(response), 500 # All failed @app.route('/api/domain', methods=['DELETE']) @require_api_key def remove_domain(): data = request.get_json() domain = data.get('domain') if not domain: log_operation('remove_domain', False, 'Domain is required') return jsonify({'status': 'error', 'message': 'Domain is required'}), 400 try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Get domain ID and check if it exists cursor.execute('SELECT id FROM domains WHERE domain = ?', (domain,)) domain_result = cursor.fetchone() if not domain_result: log_operation('remove_domain', False, f'Domain {domain} not found') return jsonify({'status': 'error', 'message': 'Domain not found'}), 404 domain_id = domain_result[0] # Get backend IDs associated with this domain cursor.execute('SELECT id FROM backends WHERE domain_id = ?', (domain_id,)) backend_ids = [row[0] for row in cursor.fetchall()] # Delete backend servers for backend_id in backend_ids: cursor.execute('DELETE FROM backend_servers WHERE backend_id = ?', (backend_id,)) # Delete backends cursor.execute('DELETE FROM backends WHERE domain_id = ?', (domain_id,)) # Delete domain cursor.execute('DELETE FROM domains WHERE id = ?', (domain_id,)) # Delete SSL certificate if it exists cursor.execute('SELECT ssl_cert_path FROM domains WHERE id = ? AND ssl_enabled = 1', (domain_id,)) cert_result = cursor.fetchone() if cert_result and cert_result[0]: try: os.remove(cert_result[0]) except OSError: pass # Ignore errors if file doesn't exist # Regenerate HAProxy config generate_config() log_operation('remove_domain', True, f'Domain {domain} removed successfully') return jsonify({'status': 'success', 'message': 'Domain configuration removed'}) except Exception as e: log_operation('remove_domain', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/blocked-ips', methods=['GET']) @require_api_key def get_blocked_ips(): """Get all blocked IP addresses""" try: with sqlite3.connect(DB_FILE) as conn: conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute('SELECT * FROM blocked_ips ORDER BY blocked_at DESC') blocked_ips = [dict(row) for row in cursor.fetchall()] log_operation('get_blocked_ips', True) return jsonify(blocked_ips) except Exception as e: log_operation('get_blocked_ips', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/blocked-ips', methods=['POST']) @require_api_key def add_blocked_ip(): """Add an IP address to the blocked list""" data = request.get_json() ip_address = data.get('ip_address') reason = data.get('reason', 'No reason provided') blocked_by = data.get('blocked_by', 'API') if not ip_address: log_operation('add_blocked_ip', False, 'IP address is required') return jsonify({'status': 'error', 'message': 'IP address is required'}), 400 try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('INSERT INTO blocked_ips (ip_address, reason, blocked_by) VALUES (?, ?, ?)', (ip_address, reason, blocked_by)) blocked_ip_id = cursor.lastrowid # Update map file and add to runtime (no full reload needed) if not update_blocked_ips_map(): log_operation('add_blocked_ip', False, f'Failed to update map file for {ip_address}') return jsonify({'status': 'error', 'message': 'Failed to update blocked IPs map file'}), 500 # Add to runtime map for immediate effect add_ip_to_runtime_map(ip_address) # Reload HAProxy to ensure consistency try: if is_process_running('haproxy'): if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' reload_result = subprocess.run(f'echo "reload" | socat stdio {socket_path}', capture_output=True, text=True, shell=True) if reload_result.returncode != 0: logger.warning(f"HAProxy reload failed after blocking IP {ip_address}: {reload_result.stderr}") except Exception as e: logger.warning(f"Error reloading HAProxy after blocking IP {ip_address}: {e}") log_operation('add_blocked_ip', True, f'IP {ip_address} blocked successfully') return jsonify({'status': 'success', 'blocked_ip_id': blocked_ip_id, 'message': f'IP {ip_address} has been blocked'}) except sqlite3.IntegrityError: log_operation('add_blocked_ip', False, f'IP {ip_address} is already blocked') return jsonify({'status': 'error', 'message': 'IP address is already blocked'}), 409 except Exception as e: log_operation('add_blocked_ip', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/blocked-ips', methods=['DELETE']) @require_api_key def remove_blocked_ip(): """Remove an IP address from the blocked list""" data = request.get_json() ip_address = data.get('ip_address') if not ip_address: log_operation('remove_blocked_ip', False, 'IP address is required') return jsonify({'status': 'error', 'message': 'IP address is required'}), 400 try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM blocked_ips WHERE ip_address = ?', (ip_address,)) ip_result = cursor.fetchone() if not ip_result: log_operation('remove_blocked_ip', False, f'IP {ip_address} not found in blocked list') return jsonify({'status': 'error', 'message': 'IP address not found in blocked list'}), 404 cursor.execute('DELETE FROM blocked_ips WHERE ip_address = ?', (ip_address,)) # Update map file and remove from runtime (no full reload needed) if not update_blocked_ips_map(): log_operation('remove_blocked_ip', False, f'Failed to update map file for {ip_address}') return jsonify({'status': 'error', 'message': 'Failed to update blocked IPs map file'}), 500 # Remove from runtime map for immediate effect remove_ip_from_runtime_map(ip_address) # Reload HAProxy to ensure consistency try: if is_process_running('haproxy'): if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' reload_result = subprocess.run(f'echo "reload" | socat stdio {socket_path}', capture_output=True, text=True, shell=True) if reload_result.returncode != 0: logger.warning(f"HAProxy reload failed after unblocking IP {ip_address}: {reload_result.stderr}") except Exception as e: logger.warning(f"Error reloading HAProxy after unblocking IP {ip_address}: {e}") log_operation('remove_blocked_ip', True, f'IP {ip_address} unblocked successfully') return jsonify({'status': 'success', 'message': f'IP {ip_address} has been unblocked'}) except Exception as e: log_operation('remove_blocked_ip', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/config/reload', methods=['POST']) @require_api_key def reload_config_safely(): """Safely reload HAProxy configuration with validation and rollback""" try: # Regenerate config files including map generate_config() log_operation('reload_config_safely', True, 'Configuration reloaded safely') return jsonify({'status': 'success', 'message': 'HAProxy configuration reloaded safely'}) except Exception as e: log_operation('reload_config_safely', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/blocked-ips/sync', methods=['POST']) @require_api_key def sync_blocked_ips(): """Sync blocked IPs from database to runtime map""" try: # Update map file if not update_blocked_ips_map(): return jsonify({'status': 'error', 'message': 'Failed to update map file'}), 500 # Clear and reload runtime map with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT ip_address FROM blocked_ips ORDER BY ip_address') blocked_ips = [row[0] for row in cursor.fetchall()] # Try to clear all entries from runtime map (might fail if empty, that's ok) try: if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' subprocess.run(f'echo "clear map #0" | socat stdio {socket_path}', shell=True, capture_output=True) except: pass # Clear might fail if map is empty # Add all IPs to runtime map success_count = 0 for ip in blocked_ips: if add_ip_to_runtime_map(ip): success_count += 1 log_operation('sync_blocked_ips', True, f'Synced {success_count}/{len(blocked_ips)} IPs to runtime map') return jsonify({ 'status': 'success', 'message': f'Synced {success_count}/{len(blocked_ips)} IPs to runtime map', 'total_ips': len(blocked_ips), 'synced_ips': success_count }) except Exception as e: log_operation('sync_blocked_ips', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/security/stats', methods=['GET']) @require_api_key def get_security_stats(): """Get current security statistics from HAProxy stick table""" try: if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' # Get stick table data cmd = f'echo "show table web" | socat stdio {socket_path}' result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode != 0: return jsonify({'status': 'error', 'message': 'Failed to get stick table data'}), 500 # Parse stick table output lines = result.stdout.strip().split('\n') threats = [] for line in lines[1:]: # Skip header parts = line.split() if len(parts) >= 8: ip = parts[0] try: gpc0 = int(parts[3]) if len(parts) > 3 else 0 gpc1 = int(parts[4]) if len(parts) > 4 else 0 req_rate = int(parts[5]) if len(parts) > 5 else 0 err_rate = int(parts[6]) if len(parts) > 6 else 0 conn_rate = int(parts[7]) if len(parts) > 7 else 0 # Only include IPs with significant activity if gpc0 > 0 or gpc1 > 0 or req_rate > 30 or err_rate > 5 or conn_rate > 10: threat_level = 'low' if gpc1 > 2: threat_level = 'critical' elif gpc0 > 0 or err_rate > 10: threat_level = 'high' elif req_rate > 40 or conn_rate > 15: threat_level = 'medium' threats.append({ 'ip': ip, 'blocked': gpc0 > 0, 'repeat_offender': gpc1 > 2, 'offense_count': gpc1, 'request_rate': req_rate, 'error_rate': err_rate, 'connection_rate': conn_rate, 'threat_level': threat_level }) except (ValueError, IndexError): continue # Sort by threat level threats.sort(key=lambda x: (x['offense_count'], x['error_rate'], x['request_rate']), reverse=True) return jsonify({ 'status': 'success', 'total_tracked_ips': len(lines) - 1, 'active_threats': len(threats), 'threats': threats[:50] # Limit to top 50 }) except Exception as e: log_operation('get_security_stats', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/security/temporary-block', methods=['POST']) @require_api_key def temporary_block(): """Temporarily block an IP address (auto-unblocks after specified time)""" data = request.get_json() ip_address = data.get('ip_address') duration_minutes = data.get('duration_minutes', 60) # Default 1 hour if not ip_address: return jsonify({'status': 'error', 'message': 'IP address is required'}), 400 if not validate_ip_address(ip_address): return jsonify({'status': 'error', 'message': 'Invalid IP address format'}), 400 try: # Add to blocked IPs with expiration time expiry_time = datetime.now() + timedelta(minutes=duration_minutes) with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Check if table has expiry column, add if not cursor.execute("PRAGMA table_info(blocked_ips)") columns = [column[1] for column in cursor.fetchall()] if 'expiry_time' not in columns: cursor.execute('ALTER TABLE blocked_ips ADD COLUMN expiry_time TEXT') # Add or update the blocked IP with expiry cursor.execute(''' INSERT OR REPLACE INTO blocked_ips (ip_address, reason, expiry_time) VALUES (?, ?, ?) ''', (ip_address, f'Temporary block for {duration_minutes} minutes', expiry_time.isoformat())) # Update map file and add to runtime if not update_blocked_ips_map(): return jsonify({'status': 'error', 'message': 'Failed to update map file'}), 500 add_ip_to_runtime_map(ip_address) log_operation('temporary_block', True, f'Temporarily blocked {ip_address} for {duration_minutes} minutes') return jsonify({ 'status': 'success', 'message': f'IP {ip_address} temporarily blocked for {duration_minutes} minutes', 'expires_at': expiry_time.isoformat() }) except Exception as e: log_operation('temporary_block', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/security/clear-expired', methods=['POST']) @require_api_key def clear_expired_blocks(): """Remove expired temporary IP blocks""" try: current_time = datetime.now() expired_ips = [] with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() # Check if expiry_time column exists cursor.execute("PRAGMA table_info(blocked_ips)") columns = [column[1] for column in cursor.fetchall()] if 'expiry_time' in columns: # Find and remove expired blocks cursor.execute(''' SELECT ip_address FROM blocked_ips WHERE expiry_time IS NOT NULL AND expiry_time < ? ''', (current_time.isoformat(),)) expired_ips = [row[0] for row in cursor.fetchall()] # Remove expired IPs for ip in expired_ips: cursor.execute('DELETE FROM blocked_ips WHERE ip_address = ?', (ip,)) remove_ip_from_runtime_map(ip) # Update map file if any IPs were removed if expired_ips: update_blocked_ips_map() log_operation('clear_expired_blocks', True, f'Cleared {len(expired_ips)} expired IP blocks') return jsonify({ 'status': 'success', 'message': f'Cleared {len(expired_ips)} expired IP blocks', 'cleared_ips': expired_ips }) except Exception as e: log_operation('clear_expired_blocks', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 def generate_config(): try: conn = sqlite3.connect(DB_FILE) # Enable dictionary-like access to rows conn.row_factory = sqlite3.Row cursor = conn.cursor() query = ''' SELECT d.id as domain_id, d.domain, d.ssl_enabled, d.ssl_cert_path, d.template_override, b.id as backend_id, b.name as backend_name FROM domains d LEFT JOIN backends b ON d.id = b.domain_id ''' cursor.execute(query) # Fetch and immediately convert to list of dicts to avoid any cursor issues domains = [dict(domain) for domain in cursor.fetchall()] # Get blocked IPs cursor.execute('SELECT ip_address FROM blocked_ips') blocked_ips = [row[0] for row in cursor.fetchall()] config_parts = [] # Add Haproxy Default Headers default_headers = template_env.get_template('hap_header.tpl').render() config_parts.append(default_headers) # Update blocked IPs map file first update_blocked_ips_map() # Add Listener Block listener_block = template_env.get_template('hap_listener.tpl').render( crt_path = SSL_CERTS_DIR ) config_parts.append(listener_block) # Add Let's Encrypt letsencrypt_acl = template_env.get_template('hap_letsencrypt.tpl').render() config_parts.append(letsencrypt_acl) config_acls = [] config_backends = [] # Add default backend rule (will be used when no domain matches) default_rule = " # Default backend for unmatched domains\n default_backend default-backend\n" config_parts.append(default_rule) # Add domain configurations for domain in domains: if not domain['backend_name']: logger.warning(f"Skipping domain {domain['domain']} - no backend name") continue # Add domain ACL try: domain_acl = template_env.get_template('hap_subdomain_acl.tpl').render( domain=domain['domain'], name=domain['backend_name'] ) config_acls.append(domain_acl) logger.info(f"Added ACL for domain: {domain['domain']}") except Exception as e: logger.error(f"Error generating domain ACL for {domain['domain']}: {e}") continue # Add backend configuration try: cursor.execute(''' SELECT * FROM backend_servers WHERE backend_id = ? ''', (domain['backend_id'],)) servers = [dict(server) for server in cursor.fetchall()] if not servers: logger.warning(f"No servers found for backend {domain['backend_name']}") continue if domain['template_override'] is not None: logger.info(f"Template Override is set to: {domain['template_override']}") template_file = domain['template_override'] + ".tpl" backend_block = template_env.get_template(template_file).render( name=domain['backend_name'], servers=servers ) else: backend_block = template_env.get_template('hap_backend.tpl').render( name=domain['backend_name'], ssl_enabled=domain['ssl_enabled'], servers=servers ) config_backends.append(backend_block) logger.info(f"Added backend block for: {domain['backend_name']}") except Exception as e: logger.error(f"Error generating backend block for {domain['backend_name']}: {e}") continue # Add ACLS config_parts.append('\n' .join(config_acls)) # Add LetsEncrypt Backend letsencrypt_backend = template_env.get_template('hap_letsencrypt_backend.tpl').render() config_parts.append(letsencrypt_backend) # Add Security Tables try: security_tables = template_env.get_template('hap_security_tables.tpl').render() config_parts.append(security_tables) except Exception as e: logger.warning(f"Security tables template not found: {e}") # Add Default Backend try: default_backend = template_env.get_template('hap_default_backend.tpl').render() config_parts.append(default_backend) except Exception as e: logger.error(f"Error generating default backend: {e}") # Fallback to a simple default backend fallback_backend = '''# Default backend for unmatched domains backend default-backend mode http option http-server-close server default-page 127.0.0.1:8080''' config_parts.append(fallback_backend) # Add Backends config_parts.append('\n' .join(config_backends) + '\n') # Write complete configuration to tmp temp_config_path = "/etc/haproxy/haproxy.cfg" config_content = '\n'.join(config_parts) logger.debug("Generated HAProxy configuration") # Write complete configuration to tmp # Write new configuration to file with open(HAPROXY_CONFIG_PATH, 'w') as f: f.write(config_content) # Use safe reload with validation and rollback success, message = reload_haproxy_safely() if success: logger.info("Configuration generated and HAProxy reloaded safely") log_operation('generate_config', True, 'Configuration generated and HAProxy reloaded safely') else: error_msg = f"Safe reload failed: {message}" logger.error(error_msg) log_operation('generate_config', False, error_msg) raise Exception(error_msg) except Exception as e: error_msg = f"Error generating config: {e}" logger.error(error_msg) log_operation('generate_config', False, error_msg) import traceback traceback.print_exc() raise def create_backup(): """Create backup of current config and map files""" try: if os.path.exists(HAPROXY_CONFIG_PATH): shutil.copy2(HAPROXY_CONFIG_PATH, HAPROXY_BACKUP_PATH) if os.path.exists(BLOCKED_IPS_MAP_PATH): shutil.copy2(BLOCKED_IPS_MAP_PATH, BLOCKED_IPS_MAP_BACKUP_PATH) logger.info("Backups created successfully") return True except Exception as e: logger.error(f"Failed to create backup: {e}") return False def restore_backup(): """Restore from backup files""" try: if os.path.exists(HAPROXY_BACKUP_PATH): shutil.copy2(HAPROXY_BACKUP_PATH, HAPROXY_CONFIG_PATH) if os.path.exists(BLOCKED_IPS_MAP_BACKUP_PATH): shutil.copy2(BLOCKED_IPS_MAP_BACKUP_PATH, BLOCKED_IPS_MAP_PATH) logger.info("Backups restored successfully") return True except Exception as e: logger.error(f"Failed to restore backup: {e}") return False def validate_haproxy_config(): """Validate HAProxy configuration file""" try: result = subprocess.run(['haproxy', '-c', '-f', HAPROXY_CONFIG_PATH], capture_output=True, text=True) if result.returncode == 0: logger.info("HAProxy configuration validation passed") return True, None else: error_msg = f"HAProxy configuration validation failed: {result.stderr}" logger.error(error_msg) return False, error_msg except Exception as e: error_msg = f"Error validating HAProxy config: {e}" logger.error(error_msg) return False, error_msg def reload_haproxy_safely(): """Safely reload HAProxy with validation and rollback""" try: # Create backup before changes if not create_backup(): return False, "Failed to create backup" # Validate new configuration is_valid, error_msg = validate_haproxy_config() if not is_valid: # Restore backup on validation failure restore_backup() return False, f"Config validation failed: {error_msg}" # Attempt reload if is_process_running('haproxy'): # Use HAProxy stats socket for graceful reload try: if os.path.exists(HAPROXY_SOCKET_PATH): reload_result = subprocess.run( f'echo "reload" | socat stdio {HAPROXY_SOCKET_PATH}', capture_output=True, text=True, shell=True ) else: # Fallback to old socket path reload_result = subprocess.run( 'echo "reload" | socat stdio /tmp/haproxy-cli', capture_output=True, text=True, shell=True ) if reload_result.returncode == 0: logger.info("HAProxy reloaded successfully") return True, "HAProxy reloaded successfully" else: # Reload failed, restore backup restore_backup() # Try to reload with backup config subprocess.run('echo "reload" | socat stdio /tmp/haproxy-cli', shell=True, capture_output=True) error_msg = f"HAProxy reload failed: {reload_result.stderr}" logger.error(error_msg) return False, error_msg except Exception as e: # Critical error during reload, restore backup restore_backup() error_msg = f"Critical error during reload: {e}" logger.error(error_msg) return False, error_msg else: # HAProxy not running, start it try: result = subprocess.run( ['haproxy', '-W', '-S', '/tmp/haproxy-cli,level,admin', '-f', HAPROXY_CONFIG_PATH], check=True, capture_output=True, text=True ) logger.info("HAProxy started successfully") return True, "HAProxy started successfully" except subprocess.CalledProcessError as e: # Start failed, restore backup restore_backup() error_msg = f"Failed to start HAProxy: {e.stderr}" logger.error(error_msg) return False, error_msg except Exception as e: error_msg = f"Critical error in reload process: {e}" logger.error(error_msg) return False, error_msg def update_blocked_ips_map(): """Update the blocked IPs map file from database""" try: with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() cursor.execute('SELECT ip_address FROM blocked_ips ORDER BY ip_address') blocked_ips = [row[0] for row in cursor.fetchall()] # Write map file os.makedirs(os.path.dirname(BLOCKED_IPS_MAP_PATH), exist_ok=True) with open(BLOCKED_IPS_MAP_PATH, 'w') as f: for ip in blocked_ips: f.write(f"{ip}\n") logger.info(f"Updated blocked IPs map file with {len(blocked_ips)} IPs") return True except Exception as e: logger.error(f"Failed to update blocked IPs map: {e}") return False def add_ip_to_runtime_map(ip_address): """Add IP to HAProxy runtime map without reload""" try: if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' # Add to runtime map (map file ID 0 for blocked IPs) cmd = f'echo "add map #0 {ip_address}" | socat stdio {socket_path}' result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode == 0: logger.info(f"Added IP {ip_address} to runtime map") return True else: logger.warning(f"Failed to add IP to runtime map: {result.stderr}") return False except Exception as e: logger.error(f"Error adding IP to runtime map: {e}") return False def remove_ip_from_runtime_map(ip_address): """Remove IP from HAProxy runtime map without reload""" try: if os.path.exists(HAPROXY_SOCKET_PATH): socket_path = HAPROXY_SOCKET_PATH else: socket_path = '/tmp/haproxy-cli' # Remove from runtime map (map file ID 0 for blocked IPs) cmd = f'echo "del map #0 {ip_address}" | socat stdio {socket_path}' result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode == 0: logger.info(f"Removed IP {ip_address} from runtime map") return True else: logger.warning(f"Failed to remove IP from runtime map: {result.stderr}") return False except Exception as e: logger.error(f"Error removing IP from runtime map: {e}") return False def start_haproxy(): if not is_process_running('haproxy'): try: # First check if the config file exists and is valid if not os.path.exists(HAPROXY_CONFIG_PATH): logger.warning("HAProxy config file not found, skipping HAProxy start") return # Test the configuration before starting test_result = subprocess.run( ['haproxy', '-c', '-f', HAPROXY_CONFIG_PATH], capture_output=True, text=True ) if test_result.returncode != 0: logger.error(f"HAProxy configuration is invalid: {test_result.stderr}") logger.warning("Attempting to regenerate configuration...") # Try to regenerate the configuration try: generate_config() logger.info("Configuration regenerated successfully") except Exception as gen_error: logger.error(f"Failed to regenerate configuration: {gen_error}") logger.warning("HAProxy will not start due to configuration errors") log_operation('start_haproxy', False, f"Invalid config: {test_result.stderr}") return # Test the configuration again test_result = subprocess.run( ['haproxy', '-c', '-f', HAPROXY_CONFIG_PATH], capture_output=True, text=True ) if test_result.returncode != 0: logger.error(f"HAProxy configuration is still invalid after regeneration: {test_result.stderr}") logger.warning("HAProxy will not start due to configuration errors") log_operation('start_haproxy', False, f"Invalid config: {test_result.stderr}") return # Configuration is valid, start HAProxy result = subprocess.run( ['haproxy', '-W', '-S', '/tmp/haproxy-cli,level,admin', '-f', HAPROXY_CONFIG_PATH], check=True, capture_output=True, text=True ) if result.returncode == 0: logger.info("HAProxy started successfully") log_operation('start_haproxy', True, 'HAProxy started successfully') else: error_msg = f"HAProxy start command returned: {result.stdout}\nError output: {result.stderr}" logger.error(error_msg) log_operation('start_haproxy', False, error_msg) except subprocess.CalledProcessError as e: error_msg = f"Failed to start HAProxy: {e.stdout}\n{e.stderr}" logger.error(error_msg) log_operation('start_haproxy', False, error_msg) # Don't raise the exception - let the container continue without HAProxy logger.warning("Container will continue without HAProxy running") except Exception as e: error_msg = f"Unexpected error starting HAProxy: {e}" logger.error(error_msg) log_operation('start_haproxy', False, error_msg) logger.warning("Container will continue without HAProxy running") if __name__ == '__main__': init_db() certbot_register() generate_self_signed_cert(SSL_CERTS_DIR) # Always regenerate config before starting HAProxy to ensure compatibility try: generate_config() logger.info("Configuration generated successfully before startup") except Exception as e: logger.error(f"Failed to generate initial configuration: {e}") # Continue anyway, HAProxy will fail to start but the service will be available start_haproxy() certbot_register() # Run Flask app on port 8000 for API and port 8080 for default page from threading import Thread def run_default_page_server(): """Run a separate Flask app on port 8080 for the default page""" from flask import Flask, render_template default_app = Flask(__name__) default_app.template_folder = 'templates' @default_app.route('/') def default_page(): """Serve the default page for unmatched domains""" admin_email = os.environ.get('HAPROXY_ADMIN_EMAIL', 'admin@example.com') return render_template('default_page.html', page_title=os.environ.get('HAPROXY_DEFAULT_PAGE_TITLE', 'Site Not Configured'), main_message=os.environ.get('HAPROXY_DEFAULT_MAIN_MESSAGE', 'This domain has not been configured yet. Please contact your system administrator to set up this website.'), secondary_message=os.environ.get('HAPROXY_DEFAULT_SECONDARY_MESSAGE', 'If you believe this is an error, please check the domain name and try again.') ) @default_app.route('/blocked-ip') def blocked_ip_page(): """Serve the blocked IP page for blocked clients""" return render_template('blocked_ip_page.html'), 403 default_app.run(host='0.0.0.0', port=8080) # Start the default page server in a separate thread default_server_thread = Thread(target=run_default_page_server, daemon=True) default_server_thread.start() # Run the main API server app.run(host='0.0.0.0', port=8000)