Files
haproxy-manager-base/haproxy_manager.py
jknapp 65248680a5
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m54s
Fix HAProxy 3.0.11 compatibility issues
Major syntax and configuration updates for HAProxy 3.0.11:

Configuration Fixes:
- Remove conflicting stick-table declarations in frontend
- Move security tables to separate backend sections
- Fix ACL syntax errors (missing_browser_headers → separate ACLs)
- Remove unsupported add-var() syntax
- Simplify threat scoring to use flags instead of cumulative values

Security Table Architecture:
- security_blacklist: 24h persistent offender tracking
- wp_403_track: WordPress authentication failure monitoring
- Separated from main frontend table to avoid conflicts

Simplified Threat Detection:
- low_threat: Rate abuse, suspicious methods, missing headers
- medium_threat: SQL injection, directory traversal, WordPress brute force
- high_threat: Bot scanners, admin scans, shell attempts
- critical_threat: Blacklisted IPs, auto-blacklist candidates

Response System:
- Low threat: Warning headers only
- Medium threat: Tarpit delays
- High threat: Immediate deny (403)
- Critical threat: Blacklist and deny

Enhanced Compatibility:
- Removed HAProxy 2.6-specific syntax
- Updated to HAProxy 3.0.11 requirements
- Maintained security effectiveness with simpler logic
- Added security tables template integration

The system maintains comprehensive protection while being compatible
with HAProxy 3.0.11's stricter parsing and syntax requirements.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 17:29:32 -07:00

1517 lines
62 KiB
Python

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/<domain>/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/<domain>/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/<domain>/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)