Not fully working, but saving progress

This commit is contained in:
jknapp 2025-02-19 07:53:26 -08:00
parent f222b6e79a
commit 9c52edd53a
13 changed files with 376 additions and 93 deletions

9
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,3 +1,2 @@
# haproxy-manager-base
# HAProxy Manager Base
Base code for HAProxy Web Manager

View File

@ -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"]

View File

@ -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:

280
haproxy_manager.py Normal file
View File

@ -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)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
Flask==2.3.3
Jinja2==3.1.2
psutil

5
scripts/start-up.sh Normal file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Exit on error
set -eo pipefail

View File

@ -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 %}

48
templates/hap_header.tpl Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
#Path Method {{ path }}
acl {{ path }}-acl path_beg {{ path }}
use_backend {{ name }}-backend if {{ path }}-acl

View File

@ -0,0 +1,4 @@
#Subdomain method {{ domain }}
acl {{ domain }}-acl hdr(host) -i {{ domain }}
use_backend {{ name }}-backend if {{ domain }}-acl