From 305fffba42ab3edf84c8c9025d12ee7768a2281f Mon Sep 17 00:00:00 2001 From: jknapp Date: Thu, 20 Feb 2025 13:41:38 -0800 Subject: [PATCH] haproxy manager --- Dockerfile | 10 ++- README.md | 103 +++++++++++++++++++++++++++ haproxy_manager.py | 138 ++++++++++++++++++++++++++++--------- scripts/start-up.sh | 3 +- templates/hap_backend.tpl | 7 +- templates/hap_path_acl.tpl | 1 + 6 files changed, 224 insertions(+), 38 deletions(-) diff --git a/Dockerfile b/Dockerfile index b45ee54..0ee515d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,15 @@ FROM python:3.12-slim -RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy -y && apt clean && rm -rf /var/lib/apt/lists/* +RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot -y && apt clean && rm -rf /var/lib/apt/lists/* WORKDIR /haproxy COPY ./templates /haproxy/templates COPY requirements.txt /haproxy/ COPY haproxy_manager.py /haproxy/ +COPY scripts /haproxy/scripts +RUN chmod +x /haproxy/scripts/* RUN pip install -r requirements.txt +RUN echo "0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew --no-random-sleep-on-renew" > /var/spool/cron/crontabs/root EXPOSE 80 443 8000 -#CMD ["python", "app.py"] \ No newline at end of file +# Add health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 +CMD ["/haproxy/scripts/start-up.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 8b18e22..2aaf4fd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,105 @@ # HAProxy Manager Base +A Flask-based API service for managing HAProxy configurations, domains, and SSL certificates. + +## API Endpoints + +### Health Check +Check the status of the HAProxy Manager service. + +```bash +GET /health + +# Response +{ + "status": "healthy", + "haproxy_status": "running", + "database": "connected" +} +``` + +### Add Domain +Add a new domain with backend servers configuration. + +```bash +POST /api/domain +Content-Type: application/json + +{ + "domain": "example.com", + "backend_name": "example_backend", + "servers": [ + { + "name": "server1", + "address": "10.0.0.1", + "port": 8080, + "options": "check" + }, + { + "name": "server2", + "address": "10.0.0.2", + "port": 8080, + "options": "check backup" + } + ] +} + +# Response +{ + "status": "success", + "domain_id": 1 +} +``` + +### Enable SSL +Request and configure SSL certificate for a domain using Let's Encrypt. + +```bash +POST /api/ssl +Content-Type: application/json + +{ + "domain": "example.com" +} + +# Response +{ + "status": "success" +} +``` + +### Remove Domain +Remove a domain and its associated backend configuration. + +```bash +DELETE /api/domain +Content-Type: application/json + +{ + "domain": "example.com" +} + +# Response +{ + "status": "success", + "message": "Domain configuration removed" +} +``` + +## Features + +- Automatic HAProxy configuration generation +- Let's Encrypt SSL certificate integration +- Backend server management +- Self-signed certificate generation for development +- Health monitoring +- Database-backed configuration storage + +## Requirements + +- HAProxy +- Python 3.x +- Flask +- SQLite3 +- Certbot (for Let's Encrypt certificates) +- OpenSSL (for self-signed certificates) diff --git a/haproxy_manager.py b/haproxy_manager.py index af548ba..059fe42 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -5,7 +5,6 @@ from pathlib import Path import subprocess import jinja2 import socket -import shutil import psutil app = Flask(__name__) @@ -18,7 +17,7 @@ SSL_CERTS_DIR = '/etc/haproxy/certs' def init_db(): with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() - + # Create domains table cursor.execute(''' CREATE TABLE IF NOT EXISTS domains ( @@ -28,7 +27,7 @@ def init_db(): ssl_cert_path TEXT ) ''') - + # Create backends table cursor.execute(''' CREATE TABLE IF NOT EXISTS backends ( @@ -39,7 +38,7 @@ def init_db(): FOREIGN KEY (domain_id) REFERENCES domains (id) ) ''') - + # Create backend_servers table cursor.execute(''' CREATE TABLE IF NOT EXISTS backend_servers ( @@ -57,7 +56,7 @@ def init_db(): 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) + print(self_sign_cert) if os.path.exists(self_sign_cert): print("Self Signed Cert Found") return True @@ -68,14 +67,14 @@ def generate_self_signed_cert(ssl_certs_dir): DOMAIN = socket.gethostname() # Generate private key and certificate subprocess.run([ - 'openssl', 'req', '-x509', '-newkey', 'rsa:4096', + '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']: @@ -95,32 +94,55 @@ def is_process_running(process_name): template_loader = jinja2.FileSystemLoader(TEMPLATE_DIR) template_env = jinja2.Environment(loader=template_loader) +@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/domain', methods=['POST']) def add_domain(): data = request.get_json() domain = data.get('domain') backend_name = data.get('backend_name') servers = data.get('servers', []) - + with sqlite3.connect(DB_FILE) as conn: cursor = conn.cursor() - + # Add domain cursor.execute('INSERT INTO domains (domain) VALUES (?)', (domain,)) domain_id = cursor.lastrowid - + # Add backend cursor.execute('INSERT INTO backends (name, domain_id) VALUES (?, ?)', (backend_name, domain_id)) backend_id = cursor.lastrowid - + # Add servers for server in servers: cursor.execute(''' - INSERT INTO backend_servers + INSERT INTO backend_servers (backend_id, server_name, server_address, server_port, server_options) VALUES (?, ?, ?, ?, ?) - ''', (backend_id, server['name'], server['address'], + ''', (backend_id, server['name'], server['address'], server['port'], server.get('options'))) # Close cursor and connection cursor.close() @@ -132,28 +154,28 @@ def add_domain(): def request_ssl(): data = request.get_json() domain = data.get('domain') - + # Request Let's Encrypt certificate result = subprocess.run([ - 'certbot', 'certonly', '--standalone', + 'certbot', 'certonly', '--standalone', '--preferred-challenges', 'http', '-d', domain, '--non-interactive --http-01-port=8688' ]) - + 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' - + 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 + UPDATE domains SET ssl_enabled = 1, ssl_cert_path = ? WHERE domain = ? ''', (combined_path, domain)) @@ -164,15 +186,67 @@ def request_ssl(): return jsonify({'status': 'success'}) return jsonify({'status': 'error', 'message': 'Failed to obtain SSL certificate'}) +@app.route('/api/domain', methods=['DELETE']) +def remove_domain(): + data = request.get_json() + domain = data.get('domain') + + if not domain: + 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: + 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() + + return jsonify({'status': 'success', 'message': 'Domain configuration removed'}) + + except Exception as 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 + SELECT d.id as domain_id, d.domain, d.ssl_enabled, @@ -183,7 +257,7 @@ def generate_config(): 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()] config_parts = [] @@ -226,7 +300,7 @@ def generate_config(): SELECT * FROM backend_servers WHERE backend_id = ? ''', (domain['backend_id'],)) servers = [dict(server) for server in cursor.fetchall()] - + if not servers: print(f"No servers found for backend {domain['backend_name']}") # Debug log continue @@ -241,19 +315,21 @@ def generate_config(): except Exception as e: print(f"Error generating backend block for {domain['backend_name']}: {e}") continue - - # Write complete configuration to tmp + + # Write complete configuration to tmp + temp_config_path = "/etc/haproxy/haproxy.cfg" + config_content = '\n'.join(config_parts) print("Final config content:", config_content) # Debug log - + # Write complete configuration to tmp # Check HAProxy Configuration, and reload if it works - with open("/tmp/haproxy_temp.cfg", 'w') as f: - f.write('\n'.join(config_parts)) - result = subprocess.run(['haproxy', '-c', '-f', "/tmp/haproxy_temp.cfg"], capture_output=True) + with open(temp_config_path, 'w') as f: + f.write(config_content) + + result = subprocess.run(['haproxy', '-c', '-f', temp_config_path], capture_output=True) if result.returncode == 0: - shutil.copyfile("/tmp/haproxy_temp.cfg", HAPROXY_CONFIG_PATH) - os.remove("/tmp/haproxy_temp.cfg") + print("HAProxy configuration check passed") if is_process_running('haproxy'): subprocess.run(['echo', '"reload"', '|', 'socat', 'stdio', '/tmp/haproxy-cli']) else: diff --git a/scripts/start-up.sh b/scripts/start-up.sh index 76e0274..f39a986 100644 --- a/scripts/start-up.sh +++ b/scripts/start-up.sh @@ -2,4 +2,5 @@ # Exit on error set -eo pipefail - +cron & +python /haproxy/haproxy_manager.py diff --git a/templates/hap_backend.tpl b/templates/hap_backend.tpl index 754c901..c95383b 100644 --- a/templates/hap_backend.tpl +++ b/templates/hap_backend.tpl @@ -1,9 +1,8 @@ backend {{ name }}-backend - option forwardfor http-request add-header X-CLIENT-IP %[src] - {% if ssl_enabled %} ttp-request set-header X-Forwarded-Proto https if \{ ssl_fc \} {% endif %} + {% if ssl_enabled %}http-request set-header X-Forwarded-Proto https if { ssl_fc }{% endif %} {% for server in servers %} - server {{ server.name }} {{ server.address }}:{{ server.port }} {{ server.options }} - {% endfor %} + server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} + {% endfor %} \ No newline at end of file diff --git a/templates/hap_path_acl.tpl b/templates/hap_path_acl.tpl index 91e7c25..2748569 100644 --- a/templates/hap_path_acl.tpl +++ b/templates/hap_path_acl.tpl @@ -1,3 +1,4 @@ + #Path Method {{ path }} acl {{ path }}-acl path_beg {{ path }} use_backend {{ name }}-backend if {{ path }}-acl