From 9c52edd53ac5844ec0cbc075d9c44319af9bf6cd Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 19 Feb 2025 07:53:26 -0800 Subject: [PATCH] Not fully working, but saving progress --- Dockerfile | 9 + README.md | 3 +- docker/Dockerfile | 59 ------- docker/docker-compose.yml | 32 ---- haproxy_manager.py | 280 ++++++++++++++++++++++++++++++++ requirements.txt | 3 + scripts/start-up.sh | 5 + templates/hap_backend.tpl | 9 + templates/hap_header.tpl | 48 ++++++ templates/hap_letsencrypt.tpl | 9 + templates/hap_listener.tpl | 5 + templates/hap_path_acl.tpl | 3 + templates/hap_subdomain_acl.tpl | 4 + 13 files changed, 376 insertions(+), 93 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker/Dockerfile delete mode 100644 docker/docker-compose.yml create mode 100644 haproxy_manager.py create mode 100644 requirements.txt create mode 100644 scripts/start-up.sh create mode 100644 templates/hap_backend.tpl create mode 100644 templates/hap_header.tpl create mode 100644 templates/hap_letsencrypt.tpl create mode 100644 templates/hap_listener.tpl create mode 100644 templates/hap_path_acl.tpl create mode 100644 templates/hap_subdomain_acl.tpl diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b45ee54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +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/* +WORKDIR /haproxy +COPY ./templates /haproxy/templates +COPY requirements.txt /haproxy/ +COPY haproxy_manager.py /haproxy/ +RUN pip install -r requirements.txt +EXPOSE 80 443 8000 +#CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md index f40edcb..8b18e22 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,2 @@ -# haproxy-manager-base +# HAProxy Manager Base -Base code for HAProxy Web Manager \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index fd975aa..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# Multi-stage build -FROM node:14-alpine as frontend-builder -WORKDIR /app/frontend -COPY frontend/package*.json ./ -RUN npm install -COPY frontend/ ./ -RUN npm run build - -FROM python:3.8-slim - -# Install HAProxy and Certbot -RUN apt-get update && \ - apt-get install -y \ - haproxy \ - certbot \ - python3-certbot \ - && rm -rf /var/lib/apt/lists/* - -# Create necessary directories -RUN mkdir -p /etc/haproxy/certs \ - && mkdir -p /var/lib/haproxy \ - && mkdir -p /run/haproxy - -# Set up Python environment -WORKDIR /app -COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy backend code -COPY backend/ ./backend/ - -# Copy frontend build -COPY --from=frontend-builder /app/frontend/build ./frontend/build - -# Copy HAProxy configuration -COPY backend/templates/haproxy.cfg.j2 /etc/haproxy/haproxy.cfg.template - -# Install curl for healthcheck -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* - -# Create data directory -RUN mkdir -p /app/backend/data - -# Set permissions -RUN chown -R nobody:nogroup /app/backend/data - -# Add healthcheck -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5000/health || exit 1 - -# Create run script -RUN echo '#!/bin/sh\n\ -python backend/app.py &\n\ -haproxy -f /etc/haproxy/haproxy.cfg -db\n' > /start.sh && \ -chmod +x /start.sh - -EXPOSE 80 443 5000 - -CMD ["/start.sh"] \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index ff6f629..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: '3.8' - -services: - haproxy-manager: - build: - context: .. - dockerfile: docker/Dockerfile - ports: - - "80:80" - - "443:443" - - "5000:5000" - volumes: - - haproxy-certs:/etc/haproxy/certs - - letsencrypt:/etc/letsencrypt - - sqlite-data:/app/backend/data - environment: - - FLASK_ENV=production - - SECRET_KEY=changeme - - DATABASE_URL=sqlite:///data/haproxy-manager.db - - JWT_SECRET_KEY=change-this-in-production - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 5s - restart: unless-stopped - -volumes: - haproxy-certs: - letsencrypt: - sqlite-data: diff --git a/haproxy_manager.py b/haproxy_manager.py new file mode 100644 index 0000000..af548ba --- /dev/null +++ b/haproxy_manager.py @@ -0,0 +1,280 @@ +import sqlite3 +import os +from flask import Flask, request, jsonify +from pathlib import Path +import subprocess +import jinja2 +import socket +import shutil +import psutil + +app = Flask(__name__) + +DB_FILE = 'haproxy_config.db' +TEMPLATE_DIR = Path('templates') +HAPROXY_CONFIG_PATH = '/etc/haproxy/haproxy.cfg' +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 ( + id INTEGER PRIMARY KEY, + domain TEXT UNIQUE NOT NULL, + ssl_enabled BOOLEAN DEFAULT 0, + ssl_cert_path 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) + ) + ''') + conn.commit() + +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/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 + (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() + return jsonify({'status': 'success', 'domain_id': domain_id}) + +@app.route('/api/ssl', methods=['POST']) +def request_ssl(): + data = request.get_json() + domain = data.get('domain') + + # Request Let's Encrypt certificate + result = subprocess.run([ + '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 + SET ssl_enabled = 1, ssl_cert_path = ? + WHERE domain = ? + ''', (combined_path, domain)) + # Close cursor and connection + cursor.close() + conn.close() + generate_config() + return jsonify({'status': 'success'}) + return jsonify({'status': 'error', 'message': 'Failed to obtain SSL certificate'}) + +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, + 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()] + config_parts = [] + + # Add Haproxy Default Headers + default_headers = template_env.get_template('hap_header.tpl').render() + config_parts.append(default_headers) + + # 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) + +# Add domain configurations + for domain in domains: + if not domain['backend_name']: + print(f"Skipping domain {domain['domain']} - no backend name") # Debug log + continue + + # Add domain ACL + try: + domain_acl = template_env.get_template('hap_subdomain_acl.tpl').render( + domain=domain['domain'], + name=domain['backend_name'] + ) + config_parts.append(domain_acl) + print(f"Added ACL for domain: {domain['domain']}") # Debug log + except Exception as e: + print(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: + print(f"No servers found for backend {domain['backend_name']}") # Debug log + continue + + backend_block = template_env.get_template('hap_backend.tpl').render( + name=domain['backend_name'], + ssl_enabled=domain['ssl_enabled'], + servers=servers + ) + config_parts.append(backend_block) + print(f"Added backend block for: {domain['backend_name']}") # Debug log + except Exception as e: + print(f"Error generating backend block for {domain['backend_name']}: {e}") + continue + + # Write complete configuration to tmp + 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) + if result.returncode == 0: + shutil.copyfile("/tmp/haproxy_temp.cfg", HAPROXY_CONFIG_PATH) + os.remove("/tmp/haproxy_temp.cfg") + if is_process_running('haproxy'): + subprocess.run(['echo', '"reload"', '|', 'socat', 'stdio', '/tmp/haproxy-cli']) + else: + try: + result = subprocess.run( + ['haproxy', '-W', '-S', '/tmp/haproxy-cli,level,admin', '-f', HAPROXY_CONFIG_PATH], + check=True, + capture_output=True, + text=True + ) + print("HAProxy started successfully") + except subprocess.CalledProcessError as e: + print(f"Failed to start HAProxy: {e.stdout}\n{e.stderr}") + raise + except Exception as e: + print(f"Error generating config: {e}") + import traceback + traceback.print_exc() + raise + +if __name__ == '__main__': + init_db() + generate_self_signed_cert(SSL_CERTS_DIR) + app.run(host='0.0.0.0', port=8000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5473bef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.3.3 +Jinja2==3.1.2 +psutil \ No newline at end of file diff --git a/scripts/start-up.sh b/scripts/start-up.sh new file mode 100644 index 0000000..76e0274 --- /dev/null +++ b/scripts/start-up.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +# Exit on error +set -eo pipefail + diff --git a/templates/hap_backend.tpl b/templates/hap_backend.tpl new file mode 100644 index 0000000..754c901 --- /dev/null +++ b/templates/hap_backend.tpl @@ -0,0 +1,9 @@ + +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 %} + {% for server in servers %} + server {{ server.name }} {{ server.address }}:{{ server.port }} {{ server.options }} + {% endfor %} diff --git a/templates/hap_header.tpl b/templates/hap_header.tpl new file mode 100644 index 0000000..ddb26b9 --- /dev/null +++ b/templates/hap_header.tpl @@ -0,0 +1,48 @@ +#--------------------------------------------------------------------- +# Global settings +#--------------------------------------------------------------------- +global + # to have these messages end up in /var/log/haproxy.log you will + # need to: + # + # 1) configure syslog to accept network log events. This is done + # by adding the '-r' option to the SYSLOGD_OPTIONS in + # /etc/sysconfig/syslog + # + # 2) configure local2 events to go to the /var/log/haproxy.log + # file. A line like the following can be added to + # /etc/sysconfig/syslog + # + # local2.* /var/log/haproxy.log + # + log 127.0.0.1 local2 + + chroot /var/lib/haproxy + pidfile /var/run/haproxy.pid + maxconn 4000 + user haproxy + group haproxy + daemon + + tune.ssl.default-dh-param 2048 +#--------------------------------------------------------------------- +# common defaults that all the 'listen' and 'backend' sections will +# use if not designated in their block +#--------------------------------------------------------------------- +defaults + mode http + log global + option httplog + option dontlognull + option http-server-close + option forwardfor #except 127.0.0.0/8 + option redispatch + retries 3 + timeout http-request 300s + timeout queue 2m + timeout connect 120s + timeout client 10m + timeout server 10m + timeout http-keep-alive 120s + timeout check 10s + maxconn 3000 \ No newline at end of file diff --git a/templates/hap_letsencrypt.tpl b/templates/hap_letsencrypt.tpl new file mode 100644 index 0000000..98a9968 --- /dev/null +++ b/templates/hap_letsencrypt.tpl @@ -0,0 +1,9 @@ + #Let's Encrypt SSL + acl letsencrypt-acl path_beg /.well-known/acme-challenge/ + use_backend letsencrypt-backend if letsencrypt-acl + + + #Pass SSL Requests to Lets Encrypt + backend letsencrypt-backend + server letsencrypt 127.0.0.1:8688 + diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl new file mode 100644 index 0000000..b9b842e --- /dev/null +++ b/templates/hap_listener.tpl @@ -0,0 +1,5 @@ +#web +frontend web + bind 0.0.0.0:80 + # 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 diff --git a/templates/hap_path_acl.tpl b/templates/hap_path_acl.tpl new file mode 100644 index 0000000..91e7c25 --- /dev/null +++ b/templates/hap_path_acl.tpl @@ -0,0 +1,3 @@ + #Path Method {{ path }} + acl {{ path }}-acl path_beg {{ path }} + use_backend {{ name }}-backend if {{ path }}-acl diff --git a/templates/hap_subdomain_acl.tpl b/templates/hap_subdomain_acl.tpl new file mode 100644 index 0000000..5e46d92 --- /dev/null +++ b/templates/hap_subdomain_acl.tpl @@ -0,0 +1,4 @@ + + #Subdomain method {{ domain }} + acl {{ domain }}-acl hdr(host) -i {{ domain }} + use_backend {{ name }}-backend if {{ domain }}-acl