diff --git a/IP_BLOCKING_API.md b/IP_BLOCKING_API.md index 030d783..4592bed 100644 --- a/IP_BLOCKING_API.md +++ b/IP_BLOCKING_API.md @@ -10,7 +10,15 @@ The IP blocking feature allows administrators to: - View all currently blocked IP addresses - Track who blocked an IP and when -When an IP is blocked, visitors from that IP address will see a custom "Access Denied" page instead of the requested website. +When an IP is blocked, visitors from that IP address will receive a 403 Forbidden response. + +## Features + +- **Runtime IP blocking**: Changes take effect immediately without HAProxy restarts +- **Map file based**: No ACL word limits, supports unlimited blocked IPs +- **Safe configuration management**: Automatic validation and rollback on failures +- **Runtime map synchronization**: Keep database and HAProxy runtime in sync +- **Audit logging**: All operations are logged for monitoring and compliance ## API Endpoints @@ -416,7 +424,86 @@ curl -X DELETE http://localhost:8000/api/blocked-ips \ ## Notes - IP blocks are applied globally to all domains managed by HAProxy -- The blocked IP page is served with HTTP 403 Forbidden status -- Blocked IPs are persistent across HAProxy restarts (stored in database) -- HAProxy configuration is automatically regenerated when IPs are blocked/unblocked -- Consider implementing rate limiting on the API endpoints to prevent abuse \ No newline at end of file +- Changes take effect immediately without HAProxy restarts (runtime updates) +- Blocked IPs are persistent across HAProxy restarts (stored in database and map file) +- Map files support unlimited IPs (no ACL word limit restrictions) +- Consider implementing rate limiting on the API endpoints to prevent abuse + +## New API Endpoints (Map File Era) + +### 4. Safe Configuration Reload + +Safely reload the HAProxy configuration with validation and automatic rollback. + +**Endpoint:** `POST /api/config/reload` + +**Response:** +```json +{ + "status": "success", + "message": "HAProxy configuration reloaded safely" +} +``` + +**Error Response:** +```json +{ + "status": "error", + "message": "Safe reload failed: Config validation failed: ..." +} +``` + +**Example Request:** +```bash +curl -X POST http://localhost:8000/api/config/reload \ + -H "Authorization: Bearer your-api-key" +``` + +### 5. Sync Runtime Map + +Synchronize blocked IPs from database to HAProxy runtime map. + +**Endpoint:** `POST /api/blocked-ips/sync` + +**Response:** +```json +{ + "status": "success", + "message": "Synced 150/150 IPs to runtime map", + "total_ips": 150, + "synced_ips": 150 +} +``` + +**Example Request:** +```bash +curl -X POST http://localhost:8000/api/blocked-ips/sync \ + -H "Authorization: Bearer your-api-key" +``` + +## Runtime Map Commands + +For advanced users, you can interact directly with HAProxy's runtime API: + +```bash +# Add IP to runtime (immediate effect) +echo "add map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock + +# Remove IP from runtime +echo "del map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock + +# Clear all blocked IPs from runtime +echo "clear map #0" | socat stdio /var/run/haproxy.sock + +# Show all runtime map entries +echo "show map #0" | socat stdio /var/run/haproxy.sock +``` + +## Migration from ACL Method + +If you're upgrading from the old ACL-based method: + +1. **Automatic**: Just update the HAProxy Manager code - it will automatically migrate +2. **Validation**: The new system includes automatic config validation and rollback +3. **No Downtime**: Runtime updates mean no service interruptions +4. **Scalable**: No more 64 IP limit - handle thousands of blocked IPs \ No newline at end of file diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..c296013 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,185 @@ +# HAProxy Manager Migration Guide: ACL to Map Files + +## Critical Issue Fixed + +HAProxy has a **64 word limit per ACL line**, which caused the following error when too many IPs were blocked: + +``` +[ALERT] (1485) : config : parsing [/etc/haproxy/haproxy.cfg:58]: too many words, truncating after word 64, position 880: <197.5.145.73>. +[ALERT] (1485) : config : parsing [/etc/haproxy/haproxy.cfg:61] : error detected while parsing an 'http-request set-path' condition : no such ACL : 'is_blocked'. +``` + +This caused HAProxy to drop traffic for **ALL sites**, creating a critical outage. + +## Solution: Map Files + +We've migrated from ACL-based IP blocking to **HAProxy map files** which: + +✅ **No word limits** - handle millions of IPs +✅ **Runtime updates** - no config reloads needed +✅ **Better performance** - hash table lookups instead of linear search +✅ **Config validation** - automatic rollback on failures +✅ **Backup/restore** - automatic backup before any changes + +## What Changed + +### Before (Problematic ACL Method) +```haproxy +# In haproxy.cfg template +acl is_blocked src 192.168.1.1 192.168.1.2 ... (64 word limit!) +http-request set-path /blocked-ip if is_blocked +``` + +### After (Map File Method) +```haproxy +# In haproxy.cfg +http-request deny status 403 if { src -f /etc/haproxy/blocked_ips.map } + +# In /etc/haproxy/blocked_ips.map +192.168.1.1 +192.168.1.2 +64.235.37.112 +``` + +## New Features + +### 1. Safe Configuration Management +- **Automatic backups** before any changes +- **Configuration validation** before applying +- **Automatic rollback** if validation fails +- **Graceful error handling** + +### 2. Runtime IP Management +```bash +# Add IP without reload (immediate effect) +echo "add map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock + +# Remove IP without reload +echo "del map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock +``` + +### 3. New API Endpoints + +#### Safe Config Reload +```bash +curl -X POST http://localhost:8000/api/config/reload \ + -H "Authorization: Bearer your-api-key" +``` + +#### Sync Runtime Maps +```bash +curl -X POST http://localhost:8000/api/blocked-ips/sync \ + -H "Authorization: Bearer your-api-key" +``` + +## Migration Process + +### Automatic Migration +The system automatically: +1. Creates `/etc/haproxy/blocked_ips.map` from database +2. Updates HAProxy config to use map files +3. Validates new configuration +4. Creates backups before applying changes + +### Manual Migration (if needed) +```bash +# 1. Stop HAProxy manager +systemctl stop haproxy-manager + +# 2. Backup current config +cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.backup + +# 3. Update HAProxy manager code +git pull origin main + +# 4. Start HAProxy manager +systemctl start haproxy-manager + +# 5. Trigger config regeneration +curl -X POST http://localhost:8000/api/config/reload \ + -H "Authorization: Bearer your-api-key" +``` + +## Rollback Plan + +If issues occur, the system automatically: + +1. **Restores backup configuration** +2. **Reloads HAProxy with known-good config** +3. **Logs all errors for debugging** + +Manual rollback if needed: +```bash +# Restore backup +cp /etc/haproxy/haproxy.cfg.backup /etc/haproxy/haproxy.cfg +systemctl reload haproxy +``` + +## Performance Benefits + +| Feature | Old ACL Method | New Map Method | +|---------|---------------|----------------| +| **IP Limit** | 64 IPs max | Unlimited | +| **Updates** | Full reload required | Runtime updates | +| **Lookup Speed** | O(n) linear | O(1) hash table | +| **Memory Usage** | High (all in config) | Low (external file) | +| **Restart Required** | Yes | No | + +## Monitoring + +Check HAProxy manager logs for any issues: +```bash +tail -f /var/log/haproxy-manager.log +``` + +Key log entries to watch for: +- `Configuration validation passed/failed` +- `Backup created/restored` +- `Runtime map updated` +- `Safe reload completed` + +## Troubleshooting + +### Map File Not Found +```bash +# Check if map file exists +ls -la /etc/haproxy/blocked_ips.map + +# Manually create if missing +curl -X POST http://localhost:8000/api/blocked-ips/sync \ + -H "Authorization: Bearer your-api-key" +``` + +### Runtime Updates Not Working +```bash +# Check HAProxy stats socket +ls -la /var/run/haproxy.sock /tmp/haproxy-cli + +# Test socket connection +echo "show info" | socat stdio /var/run/haproxy.sock +``` + +### Config Validation Failures +The system automatically: +1. Creates backup before changes +2. Validates new config +3. Restores backup if validation fails +4. Logs detailed error messages + +## Future Enhancements + +- **Geographic IP blocking** using map files +- **Rate limiting integration** +- **Automatic threat feed integration** +- **API rate limiting per client** + +## HAProxy Version Compatibility + +Map files require **HAProxy 1.6+** (released December 2015) +- ✅ HAProxy 1.6+ (Map files supported) +- ❌ HAProxy 1.5 and older (Not supported) + +Check your version: +```bash +haproxy -v +``` \ No newline at end of file diff --git a/__pycache__/haproxy_manager.cpython-310.pyc b/__pycache__/haproxy_manager.cpython-310.pyc index 362c3ca..cdeb214 100644 Binary files a/__pycache__/haproxy_manager.cpython-310.pyc and b/__pycache__/haproxy_manager.cpython-310.pyc differ diff --git a/haproxy_manager.py b/haproxy_manager.py index 1e2b65a..4cb5f65 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -10,6 +10,8 @@ import functools import logging from datetime import datetime import json +import shutil +import tempfile app = Flask(__name__) @@ -17,6 +19,10 @@ app = Flask(__name__) 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 @@ -746,8 +752,13 @@ def add_blocked_ip(): (ip_address, reason, blocked_by)) blocked_ip_id = cursor.lastrowid - # Regenerate HAProxy config to apply the block - generate_config() + # 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) 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'}) @@ -781,8 +792,13 @@ def remove_blocked_ip(): cursor.execute('DELETE FROM blocked_ips WHERE ip_address = ?', (ip_address,)) - # Regenerate HAProxy config to remove the block - generate_config() + # 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) log_operation('remove_blocked_ip', True, f'IP {ip_address} unblocked successfully') return jsonify({'status': 'success', 'message': f'IP {ip_address} has been unblocked'}) @@ -790,6 +806,64 @@ def remove_blocked_ip(): 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) @@ -824,10 +898,12 @@ def generate_config(): 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, - blocked_ips = blocked_ips + crt_path = SSL_CERTS_DIR ) config_parts.append(listener_block) @@ -917,44 +993,17 @@ backend default-backend logger.debug("Generated HAProxy configuration") # Write complete configuration to tmp - # Check HAProxy Configuration, and reload if it works - with open(temp_config_path, 'w') as f: + # Write new configuration to file + with open(HAPROXY_CONFIG_PATH, 'w') as f: f.write(config_content) - result = subprocess.run(['haproxy', '-c', '-f', temp_config_path], capture_output=True, text=True) - if result.returncode == 0: - logger.info("HAProxy configuration check passed") - if is_process_running('haproxy'): - 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") - log_operation('generate_config', True, 'Configuration generated and HAProxy reloaded') - else: - error_msg = f"HAProxy reload failed: {reload_result.stderr}" - logger.error(error_msg) - log_operation('generate_config', False, error_msg) - else: - try: - 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('generate_config', True, 'Configuration generated and HAProxy started') - else: - error_msg = f"HAProxy start command returned: {result.stdout}\nError output: {result.stderr}" - logger.error(error_msg) - log_operation('generate_config', 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('generate_config', False, error_msg) - raise + + # 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"HAProxy configuration check failed: {result.stderr}" + error_msg = f"Safe reload failed: {message}" logger.error(error_msg) log_operation('generate_config', False, error_msg) raise Exception(error_msg) @@ -966,6 +1015,181 @@ backend default-backend 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: diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 6860cf2..66fb8b1 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -4,11 +4,11 @@ frontend web # crt can now be a path, so it will load all .pem files in the path bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1 - {% if blocked_ips %} - # IP blocking - single ACL with all blocked IPs - acl is_blocked src{% for blocked_ip in blocked_ips %} {{ blocked_ip }}{% endfor %} + # IP blocking using map file (no word limit, runtime updates supported) + # Map file: /etc/haproxy/blocked_ips.map + # Runtime updates: echo "add map #0 IP_ADDRESS" | socat stdio /var/run/haproxy.sock + http-request deny status 403 if { src -f /etc/haproxy/blocked_ips.map } - # If IP is blocked, set path to blocked page and use default backend - http-request set-path /blocked-ip if is_blocked - use_backend default-backend if is_blocked - {% endif %} + # Alternative: redirect blocked IPs to blocked page instead of deny + # http-request set-path /blocked-ip if { src -f /etc/haproxy/blocked_ips.map } + # use_backend default-backend if { src -f /etc/haproxy/blocked_ips.map }