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 import json 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 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 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 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)