Not fully working, but saving progress
This commit is contained in:
parent
f222b6e79a
commit
9c52edd53a
9
Dockerfile
Normal file
9
Dockerfile
Normal 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"]
|
@ -1,3 +1,2 @@
|
|||||||
# haproxy-manager-base
|
# HAProxy Manager Base
|
||||||
|
|
||||||
Base code for HAProxy Web Manager
|
|
@ -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"]
|
|
@ -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
280
haproxy_manager.py
Normal 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
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
Jinja2==3.1.2
|
||||||
|
psutil
|
5
scripts/start-up.sh
Normal file
5
scripts/start-up.sh
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Exit on error
|
||||||
|
set -eo pipefail
|
||||||
|
|
9
templates/hap_backend.tpl
Normal file
9
templates/hap_backend.tpl
Normal 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
48
templates/hap_header.tpl
Normal 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
|
9
templates/hap_letsencrypt.tpl
Normal file
9
templates/hap_letsencrypt.tpl
Normal 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
|
||||||
|
|
5
templates/hap_listener.tpl
Normal file
5
templates/hap_listener.tpl
Normal 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
|
3
templates/hap_path_acl.tpl
Normal file
3
templates/hap_path_acl.tpl
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#Path Method {{ path }}
|
||||||
|
acl {{ path }}-acl path_beg {{ path }}
|
||||||
|
use_backend {{ name }}-backend if {{ path }}-acl
|
4
templates/hap_subdomain_acl.tpl
Normal file
4
templates/hap_subdomain_acl.tpl
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
#Subdomain method {{ domain }}
|
||||||
|
acl {{ domain }}-acl hdr(host) -i {{ domain }}
|
||||||
|
use_backend {{ name }}-backend if {{ domain }}-acl
|
Loading…
x
Reference in New Issue
Block a user