20 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
2cc28155fe rewrite 2025-11-04 10:41:27 +01:00
Mateusz Gruszczyński
dd31c1bdd0 rewrite 2025-11-04 10:38:46 +01:00
Mateusz Gruszczyński
762e51f886 rewrite 2025-11-04 10:36:57 +01:00
Mateusz Gruszczyński
84759f9508 rewrite 2025-11-04 10:33:32 +01:00
Mateusz Gruszczyński
5a687549a9 rewrite 2025-11-04 10:31:32 +01:00
Mateusz Gruszczyński
577dc789fc rewrite 2025-11-04 10:30:28 +01:00
Mateusz Gruszczyński
398ccce3b5 rewrite 2025-11-04 10:26:40 +01:00
Mateusz Gruszczyński
411291c8b9 rewrite 2025-11-04 10:22:32 +01:00
Mateusz Gruszczyński
bb3aa9f179 rewrite 2025-11-04 10:19:12 +01:00
Mateusz Gruszczyński
f8a05554c1 rewrite 2025-11-04 10:15:51 +01:00
Mateusz Gruszczyński
f5cfc5bb33 rewrite 2025-11-04 10:12:07 +01:00
Mateusz Gruszczyński
06fce272c1 rewrite 2025-11-04 10:10:14 +01:00
Mateusz Gruszczyński
75e3718e70 rewrite 2025-11-04 10:07:55 +01:00
Mateusz Gruszczyński
899e698353 rewrite 2025-11-04 10:04:58 +01:00
Mateusz Gruszczyński
03b7f20b8c rewrite 2025-11-04 10:03:59 +01:00
Mateusz Gruszczyński
1c6ecb9230 rewrite 2025-11-04 10:02:43 +01:00
Mateusz Gruszczyński
b85efadd87 rewrite 2025-11-04 09:59:56 +01:00
Mateusz Gruszczyński
0c321859b9 rewrite 2025-11-04 09:58:21 +01:00
Mateusz Gruszczyński
addb21bc3e rewrite 2025-11-04 09:56:37 +01:00
gru
32ef62e4ac Merge pull request 'new_functions' (#2) from new_functions into master
Reviewed-on: #2
2025-11-04 09:04:37 +01:00
36 changed files with 3952 additions and 735 deletions

View File

@@ -1,9 +1,28 @@
__pycache__ __pycache__
*.pyc *.pyc
*.pyo
*.egg-info
.git .git
.gitignore .gitignore
.env .env
*.log .env.local
.pytest_cache
venv/
.vscode .vscode
.idea
.pytest_cache
.coverage
htmlcov
dist
build
*.log
venv/
env/
.DS_Store
*.db-journal
*.swp
*.swo
*~
.temp
instance/app.db
uploads/
backups/
logs/

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# ===== FLASK =====
FLASK_ENV=production
FLASK_APP=app.py
FLASK_DEBUG=0
# ===== DATABASE =====
DATABASE_URL=sqlite:////app/instance/app.db
# ===== SECURITY =====
SECRET_KEY=your-super-secret-key-change-this
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# ===== HAPROXY =====
HAPROXY_CONFIG_PATH=/etc/haproxy/haproxy.cfg
HAPROXY_STATS_PORT=8404
# ===== LOGGING =====
LOG_LEVEL=INFO

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
venv venv
logs/* logs/*
__pycache__ __pycache__
config/* config/auth/*

View File

@@ -1,49 +1,80 @@
FROM python:3.14-rc-trixie FROM python:3.14-rc-trixie
# ===== ENV VARIABLES =====
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
# Install dependencies LABEL maintainer="HAProxy Manager" \
RUN apt-get update && apt-get install -y \ version="2.0" \
description="HAProxy Configuration Manager with SQLAlchemy"
# ===== INSTALL SYSTEM DEPENDENCIES =====
RUN apt-get update && apt-get install -y --no-install-recommends \
haproxy \ haproxy \
supervisor \
openssl \ openssl \
ca-certificates \
supervisor \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* wget \
git \
vim \
libssl-dev \
libffi-dev \
python3-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# ===== WORKDIR =====
WORKDIR /app WORKDIR /app
# Copy requirements and install # ===== COPY & INSTALL PYTHON REQUIREMENTS =====
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt
# Copy application # ===== COPY APPLICATION FILES =====
COPY app.py . COPY app.py .
COPY log_parser.py . COPY log_parser.py .
# Copy directory structure
COPY config/ config/
COPY database/ database/
COPY routes/ routes/ COPY routes/ routes/
COPY utils/ utils/ COPY utils/ utils/
COPY auth/ auth/ COPY auth/ auth/
COPY templates/ templates/ COPY templates/ templates/
COPY static/ /app/static/ COPY static/ static/
# ===== CREATE REQUIRED DIRECTORIES =====
# Create directories RUN mkdir -p /app/instance \
RUN mkdir -p /app/config/auth \ && mkdir -p /app/uploads/certificates \
&& mkdir -p /app/config/ssl \ && mkdir -p /app/backups \
&& mkdir -p /app/logs \
&& mkdir -p /etc/haproxy \
&& mkdir -p /etc/supervisor/conf.d \ && mkdir -p /etc/supervisor/conf.d \
&& mkdir -p /var/log/supervisor \ && mkdir -p /var/log/supervisor \
&& mkdir -p /etc/haproxy && mkdir -p /var/run/haproxy \
&& touch /etc/haproxy/haproxy.cfg
# Copy configs # ===== COPY CONFIGS =====
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh # ===== SET PERMISSIONS (POPRAWIONE - && ВЕЗДЕ!) =====
RUN chmod +x /entrypoint.sh && \
# Health check chmod 755 /app && \
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ chmod -R 755 /app/uploads && \
CMD curl -f https://localhost:5000 --insecure 2>/dev/null || exit 1 chmod -R 755 /app/backups && \
chmod -R 755 /app/logs
# ===== EXPOSE PORTS =====
EXPOSE 5000 80 443 8404 EXPOSE 5000 80 443 8404
# ===== HEALTHCHECK =====
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5000/api/current-user 2>/dev/null || exit 1
# ===== ENTRYPOINT =====
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,22 @@
# 1. Przygotuj environment
cp .env.example .env
# Edytuj .env - zmień SECRET_KEY i hasła!
# 2. Build images
docker-compose build
# 3. Start services
docker-compose up -d
# 4. Sprawdź logi
docker-compose logs -f haproxy-configurator
# 5. Dostęp
# Web UI: http://localhost:15001/login
# HAProxy Stats: http://localhost:8405/stats
# HAProxy HTTP: http://localhost:81
# HAProxy HTTPS: https://localhost:444 (with warning)
# 6. Zaloguj się
# Username: admin
# Password: admin123 (zmień potem!)

352
app.py
View File

@@ -1,196 +1,252 @@
"""
HAProxy Configurator - Main Application
SQLAlchemy + Flask-SQLAlchemy Integration
"""
import os import os
import sys import sys
import ssl import ssl
import configparser import configparser
from flask import Flask, render_template, render_template_string, request, jsonify from datetime import timedelta
from flask import Flask, render_template, redirect, url_for, session
from flask_sqlalchemy import SQLAlchemy
from config.settings import *
from database import db, migrate, init_db
from routes.main_routes import main_bp from routes.main_routes import main_bp
from routes.edit_routes import edit_bp from routes.edit_routes import edit_bp
from routes.auth_routes import auth_bp
from routes.user_routes import user_bp
from routes.vhost_routes import vhost_bp
from routes.cert_routes import cert_bp
from auth.auth_middleware import setup_auth, login_required
from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats
from auth.auth_middleware import setup_auth
from log_parser import parse_log_file
from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends
# ===== BASE DIRECTORY =====
BASE_DIR = os.path.abspath(os.path.dirname(__file__)) BASE_DIR = os.path.abspath(os.path.dirname(__file__))
# ===== CREATE REQUIRED DIRECTORIES =====
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
os.makedirs(INSTANCE_DIR, exist_ok=True)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(HAPROXY_BACKUP_DIR, exist_ok=True)
print(f"[APP] Base directory: {BASE_DIR}", flush=True)
print(f"[APP] Instance directory: {INSTANCE_DIR}", flush=True)
print(f"[APP] Database: {SQLALCHEMY_DATABASE_URI}", flush=True)
# ===== CREATE FLASK APP =====
app = Flask( app = Flask(
__name__, __name__,
static_folder=os.path.join(BASE_DIR, 'static'), static_folder=os.path.join(BASE_DIR, 'static'),
static_url_path='/static', static_url_path='/static',
template_folder=os.path.join(BASE_DIR, 'templates') template_folder=os.path.join(BASE_DIR, 'templates'),
instance_path=INSTANCE_DIR
) )
CONFIG_DIR_DOCKER = '/etc/haproxy-configurator'
CONFIG_DIR_LOCAL = './config'
CONFIG_DIR_ENV = os.environ.get('CONFIG_DIR', None)
if CONFIG_DIR_ENV and os.path.exists(CONFIG_DIR_ENV): # ===== LOAD CONFIGURATION =====
CONFIG_DIR = CONFIG_DIR_ENV app.config.from_object('config.settings')
elif os.path.exists(CONFIG_DIR_DOCKER): app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
CONFIG_DIR = CONFIG_DIR_DOCKER app.config['SESSION_COOKIE_SECURE'] = False
elif os.path.exists(CONFIG_DIR_LOCAL): app.config['SESSION_COOKIE_HTTPONLY'] = True
CONFIG_DIR = CONFIG_DIR_LOCAL app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
else:
CONFIG_DIR = CONFIG_DIR_DOCKER
AUTH_CFG = os.path.join(CONFIG_DIR, 'auth', 'auth.cfg')
SSL_INI = os.path.join(CONFIG_DIR, 'ssl.ini')
os.makedirs(os.path.dirname(AUTH_CFG), exist_ok=True) # ===== INITIALIZE DATABASE =====
os.makedirs(os.path.dirname(SSL_INI), exist_ok=True) print("[APP] Initializing database...", flush=True)
db.init_app(app)
migrate.init_app(app, db)
print("[APP] Database initialized", flush=True)
BASIC_AUTH_USERNAME = "admin"
BASIC_AUTH_PASSWORD = "admin"
try:
auth_config = configparser.ConfigParser()
auth_config.read(AUTH_CFG)
if auth_config.has_section('auth'):
BASIC_AUTH_USERNAME = auth_config.get('auth', 'username', fallback='admin')
BASIC_AUTH_PASSWORD = auth_config.get('auth', 'password', fallback='admin')
else:
BASIC_AUTH_USERNAME = "admin"
BASIC_AUTH_PASSWORD = "admin"
except Exception as e:
print(f"[APP] Auth config error: {e}, using defaults", flush=True)
BASIC_AUTH_USERNAME = "admin"
BASIC_AUTH_PASSWORD = "admin"
# ===== REGISTER BLUEPRINTS =====
print("[APP] Registering blueprints...", flush=True)
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(edit_bp) app.register_blueprint(edit_bp)
setup_auth(app) app.register_blueprint(auth_bp)
app.register_blueprint(user_bp)
app.register_blueprint(vhost_bp)
app.register_blueprint(cert_bp)
print("[APP] Blueprints registered", flush=True)
# ===== SETUP AUTHENTICATION MIDDLEWARE =====
print("[APP] Setting up auth middleware...", flush=True)
setup_auth(app)
print("[APP] Auth middleware setup complete", flush=True)
# ===== SSL CONTEXT SETUP =====
certificate_path = None certificate_path = None
private_key_path = None private_key_path = None
ssl_context = None ssl_context = None
try: try:
config2 = configparser.ConfigParser() if os.path.exists(SSL_INI):
config2.read(SSL_INI) config_ssl = configparser.ConfigParser()
if config2.has_section('ssl'): config_ssl.read(SSL_INI)
certificate_path = config2.get('ssl', 'certificate_path')
private_key_path = config2.get('ssl', 'private_key_path')
else:
print(f"[APP] No [ssl] section in {SSL_INI}", flush=True)
sys.exit(1)
if not os.path.exists(certificate_path): if config_ssl.has_section('ssl'):
print(f"[APP] Certificate not found: {certificate_path}", flush=True) certificate_path = config_ssl.get('ssl', 'certificate_path')
sys.exit(1) private_key_path = config_ssl.get('ssl', 'private_key_path')
if not os.path.exists(private_key_path):
print(f"[APP] Private key not found: {private_key_path}", flush=True)
sys.exit(1)
if os.path.exists(certificate_path) and os.path.exists(private_key_path):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path) ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path)
print(f"[APP] SSL context loaded", flush=True) print("[APP] SSL context loaded successfully", flush=True)
else:
print(f"[APP] SSL certificate files not found", flush=True)
print(f" Certificate: {certificate_path}", flush=True)
print(f" Private Key: {private_key_path}", flush=True)
else:
print(f"[APP] No [ssl] section in {SSL_INI}", flush=True)
else:
print(f"[APP] No SSL config file found: {SSL_INI}", flush=True)
except Exception as e: except Exception as e:
print(f"[APP] SSL error: {e}", flush=True) print(f"[APP] SSL warning (non-critical): {e}", flush=True)
sys.exit(1)
# ===== ROUTES =====
@app.route('/')
def index():
"""Index/Home - Redirect to login or dashboard"""
if 'user_id' not in session:
return redirect(url_for('auth.login'))
return redirect(url_for('main.index'))
@app.route('/statistics') @app.route('/statistics')
@login_required
def display_haproxy_stats(): def display_haproxy_stats():
"""Display HAProxy statistics"""
try:
haproxy_stats = fetch_haproxy_stats() haproxy_stats = fetch_haproxy_stats()
parsed_stats = parse_haproxy_stats(haproxy_stats) parsed_stats = parse_haproxy_stats(haproxy_stats)
return render_template('statistics.html', stats=parsed_stats) return render_template('statistics.html', stats=parsed_stats)
@app.route('/logs', endpoint='display_logs')
def display_haproxy_logs():
log_file_path = '/var/log/haproxy.log'
if not os.path.exists(log_file_path):
return render_template('logs.html',
logs=[],
total_logs=0,
error_message=f"Log file not found: {log_file_path}")
try:
logs = parse_log_file(log_file_path)
total_logs = len(logs)
# Załaduj ostatnie 200 logów
initial_logs = logs[-200:] if len(logs) > 200 else logs
return render_template('logs.html',
logs=initial_logs,
total_logs=total_logs,
loaded_count=len(initial_logs))
except Exception as e: except Exception as e:
return render_template('logs.html', print(f"[STATS] Error: {e}", flush=True)
logs=[], return render_template('statistics.html', stats={}, error=str(e))
total_logs=0,
error_message=f"Error parsing logs: {str(e)}")
@app.route('/api/logs', methods=['POST'])
def api_get_logs(): @app.route('/dashboard')
"""API endpoint for paginated and filtered logs""" @login_required
def dashboard():
"""Dashboard - Overview of vhosts and status"""
from database.models import VirtualHost
try: try:
log_file_path = '/var/log/haproxy.log' vhosts = VirtualHost.query.all()
return render_template('dashboard.html', vhosts=vhosts)
if not os.path.exists(log_file_path):
return jsonify({'error': 'Log file not found', 'success': False}), 404
page = request.json.get('page', 1)
per_page = request.json.get('per_page', 50)
search_query = request.json.get('search', '').lower()
exclude_phrases = request.json.get('exclude', [])
if page < 1:
page = 1
if per_page < 1 or per_page > 500:
per_page = 50
print(f"[API] page={page}, per_page={per_page}, search={search_query}, exclude={len(exclude_phrases)}", flush=True)
# Parse all logs
all_logs = parse_log_file(log_file_path)
total_logs = len(all_logs)
# Reverse to show newest first
all_logs = all_logs[::-1]
# Apply filters
filtered_logs = all_logs
if search_query:
filtered_logs = [log for log in filtered_logs if search_query in
f"{log.get('timestamp', '')} {log.get('ip_address', '')} {log.get('http_method', '')} {log.get('requested_url', '')}".lower()]
if exclude_phrases:
filtered_logs = [log for log in filtered_logs if not any(
phrase in f"{log.get('message', '')}" for phrase in exclude_phrases
)]
total_filtered = len(filtered_logs)
# Paginate
offset = (page - 1) * per_page
paginated_logs = filtered_logs[offset:offset + per_page]
print(f"[API] total={total_logs}, filtered={total_filtered}, returned={len(paginated_logs)}", flush=True)
return jsonify({
'success': True,
'logs': paginated_logs,
'page': page,
'per_page': per_page,
'total': total_logs,
'total_filtered': total_filtered,
'loaded_count': len(paginated_logs),
'has_more': offset + per_page < total_filtered
})
except Exception as e: except Exception as e:
print(f"[API] Error: {e}", flush=True) print(f"[DASHBOARD] Error: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500 return render_template('dashboard.html', vhosts=[], error=str(e))
@app.route('/home') # ===== ERROR HANDLERS =====
def home():
frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends() @app.errorhandler(404)
return render_template('home.html', def page_not_found(error):
frontend_count=frontend_count, """404 error handler"""
backend_count=backend_count, return render_template('404.html'), 404
acl_count=acl_count,
layer7_count=layer7_count,
layer4_count=layer4_count) @app.errorhandler(500)
def internal_error(error):
"""500 error handler"""
db.session.rollback()
return render_template('500.html'), 500
@app.errorhandler(403)
def forbidden(error):
"""403 error handler"""
return render_template('403.html'), 403
# ===== SHELL CONTEXT =====
@app.shell_context_processor
def make_shell_context():
"""Add models to Flask shell context"""
from database.models import User, Certificate, VirtualHost, BackendServer, ConfigHistory
return {
'db': db,
'User': User,
'Certificate': Certificate,
'VirtualHost': VirtualHost,
'BackendServer': BackendServer,
'ConfigHistory': ConfigHistory
}
@app.before_request
def before_request():
"""Run before each request"""
pass
@app.after_request
def after_request(response):
"""Run after each request"""
return response
# ===== CLI COMMANDS =====
@app.cli.command()
def init_db_cli():
"""Initialize database"""
init_db(app)
print("[CLI] Database initialized successfully", flush=True)
@app.cli.command()
def create_admin():
"""Create admin user"""
import getpass
username = input("Username: ")
password = getpass.getpass("Password: ")
from database.models import User
if User.query.filter_by(username=username).first():
print(f"Error: User '{username}' already exists")
return
user = User(username=username, is_admin=True)
user.set_password(password)
db.session.add(user)
db.session.commit()
print(f"[CLI] Admin user '{username}' created successfully")
# ===== MAIN ENTRY POINT =====
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='::', port=5000, ssl_context=ssl_context, debug=True) print("""
╔════════════════════════════════════════╗
║ HAProxy Configurator & Manager ║
║ Starting Application... ║
╚════════════════════════════════════════╝
""", flush=True)
print(f"[APP] Environment: {'Development' if DEBUG else 'Production'}", flush=True)
print(f"[APP] Running on: https://[::]:5000 (IPv6)", flush=True)
# Initialize database before running
init_db(app)
app.run(
host='::',
port=5000,
ssl_context=None,
debug=DEBUG,
use_reloader=DEBUG
)

View File

@@ -1,53 +1,39 @@
import os """Auth middleware - Updated for database"""
import functools
from flask import request, Response
import configparser
# Docker paths from functools import wraps
CONFIG_DIR = './config' from flask import session, redirect, url_for
AUTH_CFG = os.path.join(CONFIG_DIR, 'auth', 'auth.cfg') from database.models import User
# Ensure config directory exists
os.makedirs(os.path.dirname(AUTH_CFG), exist_ok=True)
# Load auth credentials with fallback defaults def login_required(f):
BASIC_AUTH_USERNAME = "admin" """Require login for view"""
BASIC_AUTH_PASSWORD = "admin" @wraps(f)
try:
if os.path.exists(AUTH_CFG):
auth_config = configparser.ConfigParser()
auth_config.read(AUTH_CFG)
if auth_config.has_section('auth'):
BASIC_AUTH_USERNAME = auth_config.get('auth', 'username', fallback='admin')
BASIC_AUTH_PASSWORD = auth_config.get('auth', 'password', fallback='admin')
print(f"[AUTH] Loaded credentials from {AUTH_CFG}", flush=True)
else:
print(f"[AUTH] No [auth] section in {AUTH_CFG}, using defaults", flush=True)
else:
print(f"[AUTH] {AUTH_CFG} not found, using defaults", flush=True)
except Exception as e:
print(f"[AUTH] Error loading config: {e}, using defaults", flush=True)
def check_auth(username, password):
return username == BASIC_AUTH_USERNAME and password == BASIC_AUTH_PASSWORD
def authenticate():
return Response(
'Could not verify your access level for that URL.\n'
'You have to login with proper credentials',
401,
{'WWW-Authenticate': 'Basic realm="Login Required"'}
)
def requires_auth(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
auth = request.authorization if 'user_id' not in session:
if not auth or not check_auth(auth.username, auth.password): return redirect(url_for('auth.login', next=request.url))
return authenticate()
# Verify user still exists
user = User.query.get(session['user_id'])
if not user:
session.clear()
return redirect(url_for('auth.login'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def setup_auth(app): def setup_auth(app):
pass """Setup auth for Flask app"""
@app.before_request
def before_request():
"""Before each request - update session user info"""
if 'user_id' in session:
user = User.query.get(session['user_id'])
if user:
# Sync session data
session['username'] = user.username
session['is_admin'] = user.is_admin
else:
# User was deleted
session.clear()

1
config/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Application configuration module"""

48
config/settings.py Normal file
View File

@@ -0,0 +1,48 @@
"""Application Settings"""
import os
from datetime import timedelta
# ===== ENVIRONMENT =====
DEBUG = os.getenv('FLASK_DEBUG', 'False').lower() == 'true'
ENV = os.getenv('FLASK_ENV', 'production')
# ===== BASE PATHS =====
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads/certificates')
HAPROXY_BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
# ===== DATABASE =====
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
f'sqlite:///{os.path.join(INSTANCE_DIR, "app.db")}'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# ===== FLASK SETTINGS =====
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# ===== HAPROXY =====
HAPROXY_CONFIG_PATH = os.getenv('HAPROXY_CONFIG_PATH', '/etc/haproxy/haproxy.cfg')
HAPROXY_BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
HAPROXY_STATS_PORT = int(os.getenv('HAPROXY_STATS_PORT', '8404'))
# ===== SSL =====
SSL_INI = os.path.join(BASE_DIR, 'config', 'ssl.ini')
# ===== MAX UPLOAD SIZE =====
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
# ===== LOGGING =====
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE = os.path.join(BASE_DIR, 'logs', 'app.log')
os.makedirs(INSTANCE_DIR, exist_ok=True)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(HAPROXY_BACKUP_DIR, exist_ok=True)
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)

45
database/__init__.py Normal file
View File

@@ -0,0 +1,45 @@
"""Database module initialization"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import logging
db = SQLAlchemy()
migrate = Migrate()
logger = logging.getLogger(__name__)
def init_db(app):
"""Initialize database - create tables"""
with app.app_context():
try:
db.create_all()
print("[DB] All tables created successfully", flush=True)
except Exception as e:
print(f"[DB] Error creating tables: {e}", flush=True)
raise
from database.models import User
try:
admin = User.query.filter_by(username='admin').first()
if not admin:
print("[DB] Creating default admin user...", flush=True)
admin = User(username='admin', is_admin=True)
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
print("[DB] Default admin user created (admin/admin123)", flush=True)
print(f"[DB] Hash: {admin.password_hash}", flush=True)
else:
print("[DB] Admin user already exists", flush=True)
except Exception as e:
print(f"[DB] Error creating admin: {e}", flush=True)
db.session.rollback()
raise

83
database/migration.py Normal file
View File

@@ -0,0 +1,83 @@
"""Migration: Import existing HAProxy config to database"""
import re
from database import db
from database.models import VirtualHost, BackendServer
def parse_existing_haproxy_config(config_path):
"""Parse existing haproxy.cfg and import to DB"""
try:
with open(config_path, 'r') as f:
config_content = f.read()
print(f"[MIGRATION] Parsing {config_path}...")
# Pattern: frontend FNAME ...
frontend_pattern = r'frontend\s+(\S+)\s*\n(.*?)(?=\n\nbackend|\Z)'
frontends = re.findall(frontend_pattern, config_content, re.DOTALL)
vhost_count = 0
for fe_name, fe_content in frontends:
# Skip stats frontend
if 'stats' in fe_name.lower():
continue
# Extract bind
bind_match = re.search(r'bind\s+([^\s:]+):(\d+)', fe_content)
if not bind_match:
continue
ip, port = bind_match.groups()
# Extract backend name
backend_match = re.search(r'default_backend\s+(\S+)', fe_content)
backend_name = backend_match.group(1) if backend_match else f'be_{fe_name}'
# Check if already exists
vhost = VirtualHost.query.filter_by(name=fe_name).first()
if vhost:
print(f"[MIGRATION] Vhost '{fe_name}' already exists, skipping...")
continue
# Create vhost
vhost = VirtualHost(
name=fe_name,
hostname=f"{fe_name}.local",
frontend_ip=ip,
frontend_port=int(port),
protocol='http',
use_ssl='ssl' in fe_content,
enabled=True
)
db.session.add(vhost)
db.session.flush() # Get vhost.id
# Parse backend servers
be_pattern = rf'backend\s+{re.escape(backend_name)}\s*\n(.*?)(?=\nbackend|\Z)'
be_match = re.search(be_pattern, config_content, re.DOTALL)
if be_match:
servers_pattern = r'server\s+(\S+)\s+([^\s:]+):(\d+)'
servers = re.findall(servers_pattern, be_match.group(1))
for srv_name, srv_ip, srv_port in servers:
server = BackendServer(
vhost_id=vhost.id,
name=srv_name,
ip_address=srv_ip,
port=int(srv_port),
enabled=True
)
db.session.add(server)
vhost_count += 1
db.session.commit()
print(f"[MIGRATION] Successfully imported {vhost_count} vhosts!", flush=True)
return vhost_count
except Exception as e:
db.session.rollback()
print(f"[MIGRATION] Error: {e}", flush=True)
return 0

170
database/models.py Normal file
View File

@@ -0,0 +1,170 @@
"""Database Models"""
from datetime import datetime
from database import db
from werkzeug.security import generate_password_hash, check_password_hash
import json
class User(db.Model):
"""User model for authentication"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
def set_password(self, password):
"""Hash and set password"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Verify password"""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Certificate(db.Model):
"""SSL Certificate storage"""
__tablename__ = 'certificates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
cert_content = db.Column(db.Text, nullable=False) # Full PEM (cert + key combined)
cert_only = db.Column(db.Text) # Separate cert (for info)
key_only = db.Column(db.Text) # Separate key (for backup)
# Metadata
common_name = db.Column(db.String(255))
subject_alt_names = db.Column(db.Text) # JSON array
issued_at = db.Column(db.DateTime)
expires_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
# Relationships
vhosts = db.relationship('VirtualHost', backref='certificate', lazy=True)
def get_san_list(self):
"""Get Subject Alternative Names as list"""
if self.subject_alt_names:
try:
return json.loads(self.subject_alt_names)
except:
return []
return []
def set_san_list(self, san_list):
"""Set Subject Alternative Names from list"""
self.subject_alt_names = json.dumps(san_list)
def __repr__(self):
return f'<Certificate {self.name}>'
class VirtualHost(db.Model):
"""Virtual Host / Proxy configuration"""
__tablename__ = 'virtual_hosts'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
hostname = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text)
# ===== FRONTEND SETTINGS =====
frontend_ip = db.Column(db.String(50), default='0.0.0.0')
frontend_port = db.Column(db.Integer, default=443)
protocol = db.Column(db.String(10), default='http') # http or tcp
# ===== SSL SETTINGS =====
use_ssl = db.Column(db.Boolean, default=False)
certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id'))
ssl_redirect = db.Column(db.Boolean, default=False)
ssl_redirect_port = db.Column(db.Integer, default=80)
# ===== LOAD BALANCING =====
lb_method = db.Column(db.String(50), default='roundrobin') # roundrobin, leastconn, source, uri
# ===== SECURITY OPTIONS =====
dos_protection = db.Column(db.Boolean, default=False)
dos_ban_duration = db.Column(db.String(20), default='30m')
dos_limit_requests = db.Column(db.Integer, default=100)
sql_injection_check = db.Column(db.Boolean, default=False)
xss_check = db.Column(db.Boolean, default=False)
webshell_check = db.Column(db.Boolean, default=False)
# ===== HEADERS =====
add_custom_header = db.Column(db.Boolean, default=False)
custom_header_name = db.Column(db.String(200))
custom_header_value = db.Column(db.String(500))
del_server_header = db.Column(db.Boolean, default=False)
forward_for = db.Column(db.Boolean, default=True)
# ===== STATE =====
enabled = db.Column(db.Boolean, default=True, index=True)
# ===== TIMESTAMPS =====
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
# Relationships
backend_servers = db.relationship('BackendServer', backref='vhost',
lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<VirtualHost {self.name}>'
class BackendServer(db.Model):
"""Backend server for virtual host"""
__tablename__ = 'backend_servers'
id = db.Column(db.Integer, primary_key=True)
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'), nullable=False)
# Server info
name = db.Column(db.String(100), nullable=False)
ip_address = db.Column(db.String(50), nullable=False)
port = db.Column(db.Integer, nullable=False)
maxconn = db.Column(db.Integer)
weight = db.Column(db.Integer, default=1)
# Health check
health_check = db.Column(db.Boolean, default=False)
health_check_path = db.Column(db.String(200), default='/')
# State
enabled = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
def __repr__(self):
return f'<BackendServer {self.name}:{self.port}>'
class ConfigHistory(db.Model):
"""History of HAProxy configuration changes"""
__tablename__ = 'config_history'
id = db.Column(db.Integer, primary_key=True)
config_content = db.Column(db.Text, nullable=False)
change_type = db.Column(db.String(50)) # vhost_create, vhost_edit, vhost_delete, manual_edit
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'))
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
# Relationships
vhost = db.relationship('VirtualHost')
user = db.relationship('User')
def __repr__(self):
return f'<ConfigHistory {self.change_type} at {self.created_at}>'

View File

@@ -1,40 +1,115 @@
version: '3.9'
services: services:
haproxy-configurator: haproxy-configurator-app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: haproxy-configurator container_name: haproxy-configurator-app
restart: unless-stopped restart: unless-stopped
# ===== PORTS =====
ports: ports:
- "15000:5000" - "15001:5000" # Flask app (manager UI)
- "80:80" - "81:80" # HAProxy HTTP
- "443:443" - "444:443" # HAProxy HTTPS
- "8404:8404" - "8405:8404" # HAProxy Stats (hardcoded 8404 inside)
# ===== VOLUMES =====
volumes: volumes:
- ./config:/app/config # Application data
- ./haproxy:/etc/haproxy - ./instance:/app/instance # SQLite database
- ./logs:/var/log - ./uploads/certificates:/app/uploads/certificates # SSL certificates
- ./ssl:/app/ssl - ./backups:/app/backups # Config backups
# HAProxy config
- ./haproxy:/etc/haproxy # HAProxy config directory
- ./logs/haproxy:/var/log/haproxy # HAProxy logs
# Logs
- ./logs/app:/app/logs # Application logs
- ./logs/supervisor:/var/log/supervisor # Supervisor logs
# ===== ENVIRONMENT =====
environment: environment:
# Flask
- FLASK_ENV=production - FLASK_ENV=production
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_DEBUG=0
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
# Database
- DATABASE_URL=sqlite:////app/instance/app.db
# Admin credentials (initial)
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=admin123
# Secret key (CHANGE IN PRODUCTION!)
- SECRET_KEY=change-me-in-production-$(openssl rand -hex 16)
# HAProxy
- HAPROXY_CONFIG_PATH=/etc/haproxy/haproxy.cfg
- HAPROXY_STATS_PORT=8404
# ===== CAPABILITIES =====
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_ADMIN - SYS_ADMIN
- DAC_OVERRIDE
# ===== LOGGING =====
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
labels: "service=haproxy-configurator"
networks: [intranet] # ===== HEALTHCHECK =====
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/current-user", "--fail"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ===== NETWORK =====
networks:
- intranet
# ===== DEPENDENCIES =====
depends_on:
- haproxy-configurator-init
# ===== INIT SERVICE (tworzy tabele i strukturę) =====
haproxy-configurator-init:
build:
context: .
dockerfile: Dockerfile
container_name: haproxy-configurator-init
entrypoint: >
sh -c "
echo '[INIT] Initializing database...';
python -c 'from app import app; from database import init_db; init_db(app)';
echo '[INIT] Database initialized!';
exit 0;
"
volumes:
- ./instance:/app/instance
- ./uploads/certificates:/app/uploads/certificates
- ./backups:/app/backups
environment:
- FLASK_ENV=production
- FLASK_APP=app.py
- PYTHONUNBUFFERED=1
networks:
- intranet
restart: "no"
networks: networks:
intranet: intranet:
external: true driver: bridge
# uncomment dla external network:
# external: true

View File

@@ -1,77 +1,41 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "[$(date)] Starting HAProxy Configurator..." echo "╔════════════════════════════════════════╗"
echo "║ HAProxy Manager - Entrypoint ║"
echo "║ Starting services... ║"
echo "╚════════════════════════════════════════╝"
# Create directories if they don't exist # ===== CHECK ENVIRONMENT =====
mkdir -p /app/config/auth echo "[STARTUP] Environment: ${FLASK_ENV:-production}"
mkdir -p /app/config/ssl echo "[STARTUP] Python version: $(python --version)"
mkdir -p /etc/haproxy
mkdir -p /var/log/supervisor
# Create default auth.cfg if doesn't exist # ===== INIT DATABASE =====
if [ ! -f /app/config/auth/auth.cfg ]; then echo "[STARTUP] Initializing database..."
cat > /app/config/auth/auth.cfg <<EOF python -c "
[auth] from app import app
username = admin from database import init_db
password = admin123 with app.app_context():
EOF init_db(app)
echo "[$(date)] Created default auth.cfg" print('[STARTUP] Database initialized successfully')
fi " || {
echo "[ERROR] Database initialization failed!"
exit 1
}
# Create default ssl.ini if doesn't exist # ===== GENERATE INITIAL HAPROXY CONFIG =====
if [ ! -f /app/config/ssl.ini ]; then echo "[STARTUP] Generating initial HAProxy config..."
cat > /app/config/ssl.ini <<EOF python -c "
[ssl] from app import app
certificate_path = /app/config/ssl/haproxy-configurator.pem from utils.config_generator import generate_haproxy_config, save_haproxy_config
private_key_path = /app/config/ssl/haproxy-configurator.pem with app.app_context():
EOF config = generate_haproxy_config()
echo "[$(date)] Created default ssl.ini" save_haproxy_config(config)
fi print('[STARTUP] HAProxy config generated')
" || {
echo "[WARNING] Could not generate initial config, continuing..."
}
# Generate self-signed certificate if doesn't exist # ===== START SUPERVISOR (runs Flask + HAProxy + other services) =====
if [ ! -f /app/config/ssl/haproxy-configurator.pem ]; then echo "[STARTUP] Starting supervisord..."
openssl req -x509 -newkey rsa:2048 -keyout /app/config/ssl/haproxy-configurator.pem \
-out /app/config/ssl/haproxy-configurator.pem -days 365 -nodes \
-subj "/C=PL/ST=State/L=City/O=Organization/CN=haproxy-configurator.local"
chmod 600 /app/config/ssl/haproxy-configurator.pem
echo "[$(date)] Generated SSL certificate"
fi
# Create default haproxy.cfg if doesn't exist or is empty
if [ ! -s /etc/haproxy/haproxy.cfg ]; then
cat > /etc/haproxy/haproxy.cfg <<'HAPROXYCFG'
global
log stdout local0
maxconn 4096
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
listen stats
bind *:8404
stats enable
stats uri /stats
stats refresh 30s
stats show-legends
HAPROXYCFG
echo "[$(date)] Created default haproxy.cfg"
fi
# Set proper permissions
chmod 600 /app/config/ssl/haproxy-configurator.pem 2>/dev/null || true
chmod 644 /app/config/auth/auth.cfg
chmod 644 /app/config/ssl.ini
chmod 644 /etc/haproxy/haproxy.cfg
echo "[$(date)] Configuration ready"
echo "[$(date)] Starting supervisord..."
# Start supervisord
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

178
models.py Normal file
View File

@@ -0,0 +1,178 @@
"""Database Models - HAProxy Manager"""
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from config.settings import HAPROXY_STATS_PORT
import json
db = SQLAlchemy()
class User(db.Model):
"""User model for authentication"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
def set_password(self, password):
"""Hash and set password"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Verify password"""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Certificate(db.Model):
"""SSL Certificate storage"""
__tablename__ = 'certificates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
cert_content = db.Column(db.Text, nullable=False) # Full PEM (cert + key combined)
cert_only = db.Column(db.Text) # Separate cert (for info)
key_only = db.Column(db.Text) # Separate key (for backup)
# Metadata
common_name = db.Column(db.String(255))
subject_alt_names = db.Column(db.Text) # JSON array
issued_at = db.Column(db.DateTime)
expires_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
# Relationships
vhosts = db.relationship('VirtualHost', backref='certificate', lazy=True)
def get_san_list(self):
"""Get Subject Alternative Names as list"""
if self.subject_alt_names:
try:
return json.loads(self.subject_alt_names)
except:
return []
return []
def set_san_list(self, san_list):
"""Set Subject Alternative Names from list"""
self.subject_alt_names = json.dumps(san_list)
def __repr__(self):
return f'<Certificate {self.name}>'
class VirtualHost(db.Model):
"""Virtual Host / Proxy configuration"""
__tablename__ = 'virtual_hosts'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
hostname = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text)
# ===== FRONTEND SETTINGS =====
frontend_ip = db.Column(db.String(50), default='0.0.0.0')
frontend_port = db.Column(db.Integer, default=443)
protocol = db.Column(db.String(10), default='http') # http or tcp
# ===== SSL SETTINGS =====
use_ssl = db.Column(db.Boolean, default=False)
certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id'))
ssl_redirect = db.Column(db.Boolean, default=False)
ssl_redirect_port = db.Column(db.Integer, default=80)
# ===== LOAD BALANCING =====
lb_method = db.Column(db.String(50), default='roundrobin') # roundrobin, leastconn, source, uri
# ===== SECURITY OPTIONS =====
dos_protection = db.Column(db.Boolean, default=False)
dos_ban_duration = db.Column(db.String(20), default='30m')
dos_limit_requests = db.Column(db.Integer, default=100)
sql_injection_check = db.Column(db.Boolean, default=False)
xss_check = db.Column(db.Boolean, default=False)
webshell_check = db.Column(db.Boolean, default=False)
# ===== HEADERS =====
add_custom_header = db.Column(db.Boolean, default=False)
custom_header_name = db.Column(db.String(200))
custom_header_value = db.Column(db.String(500))
del_server_header = db.Column(db.Boolean, default=False)
forward_for = db.Column(db.Boolean, default=True)
# ===== STATE =====
enabled = db.Column(db.Boolean, default=True, index=True)
# ===== TIMESTAMPS =====
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
# Relationships
backend_servers = db.relationship('BackendServer', backref='vhost',
lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<VirtualHost {self.name}>'
class BackendServer(db.Model):
"""Backend server for virtual host"""
__tablename__ = 'backend_servers'
id = db.Column(db.Integer, primary_key=True)
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'), nullable=False)
# Server info
name = db.Column(db.String(100), nullable=False)
ip_address = db.Column(db.String(50), nullable=False)
port = db.Column(db.Integer, nullable=False)
maxconn = db.Column(db.Integer)
weight = db.Column(db.Integer, default=1)
# Health check
health_check = db.Column(db.Boolean, default=False)
health_check_path = db.Column(db.String(200), default='/')
# State
enabled = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
def __repr__(self):
return f'<BackendServer {self.name}:{self.port}>'
class ConfigHistory(db.Model):
"""History of HAProxy configuration changes"""
__tablename__ = 'config_history'
id = db.Column(db.Integer, primary_key=True)
config_content = db.Column(db.Text, nullable=False)
change_type = db.Column(db.String(50)) # vhost_create, vhost_edit, vhost_delete, manual_edit
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'))
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
# Relationships
vhost = db.relationship('VirtualHost')
user = db.relationship('User')
def __repr__(self):
return f'<ConfigHistory {self.change_type} at {self.created_at}>'
# ===== CONSTANTS (dla config_generator.py) =====
# HAPROXY_STATS_PORT importujemy z config.settings
# Default: 8404 (hardcoded w config/settings.py)

View File

@@ -1,4 +1,11 @@
flask Flask
requests Flask-SQLAlchemy
pyOpenSSL Flask-Migrate
Werkzeug Werkzeug
python-dateutil
cryptography
pyopenssl
requests
Jinja2
Markupsafe
pyOpenSSL

122
routes/auth_routes.py Normal file
View File

@@ -0,0 +1,122 @@
"""Authentication routes - Login, Logout"""
from flask import Blueprint, render_template, request, redirect, url_for, session, jsonify
from functools import wraps
from database import db
from database.models import User
import logging
auth_bp = Blueprint('auth', __name__)
logger = logging.getLogger(__name__)
def login_required(f):
"""Decorator - require user to be logged in"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""Decorator - require admin role"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('auth.login'))
user = User.query.get(session['user_id'])
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required', 'success': False}), 403
return f(*args, **kwargs)
return decorated_function
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Login page and authentication"""
if request.method == 'GET':
# Check if already logged in
if 'user_id' in session:
return redirect(url_for('main.index'))
return render_template('login.html')
# POST - process login
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
if not username or not password:
return render_template('login.html', error='Username and password required'), 400
try:
# Find user
user = User.query.filter_by(username=username).first()
if not user:
logger.warning(f"[AUTH] Login failed - user '{username}' not found", flush=True)
return render_template('login.html', error='Invalid credentials'), 401
# Check password
if not user.check_password(password):
logger.warning(f"[AUTH] Login failed - wrong password for '{username}'", flush=True)
return render_template('login.html', error='Invalid credentials'), 401
session.clear()
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
session.permanent = True
# Zaloguj w basie danych
from datetime import datetime
user.last_login = datetime.utcnow()
db.session.commit()
logger.info(f"[AUTH] User '{username}' logged in successfully", flush=True)
# Redirect do dashboard
return redirect(url_for('main.index'))
except Exception as e:
logger.error(f"[AUTH] Login error: {e}", flush=True)
return render_template('login.html', error='Login error'), 500
@auth_bp.route('/logout', methods=['GET', 'POST'])
def logout():
"""Logout"""
username = session.get('username', 'unknown')
session.clear()
logger.info(f"[AUTH] User '{username}' logged out", flush=True)
return redirect(url_for('auth.login'))
@auth_bp.route('/api/current-user', methods=['GET'])
def current_user():
"""Get current logged in user info"""
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated', 'success': False}), 401
try:
user = User.query.get(session['user_id'])
if not user:
session.clear()
return jsonify({'error': 'User not found', 'success': False}), 401
return jsonify({
'success': True,
'user': {
'id': user.id,
'username': user.username,
'is_admin': user.is_admin
}
})
except Exception as e:
logger.error(f"[AUTH] Error getting current user: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500

217
routes/cert_routes.py Normal file
View File

@@ -0,0 +1,217 @@
"""Certificate Management - Upload, List, Delete"""
from flask import Blueprint, request, jsonify, session
from functools import wraps
from database import db
from database.models import Certificate, VirtualHost
from utils.cert_manager import parse_certificate, save_cert_file, delete_cert_file
from config.settings import UPLOAD_FOLDER, MAX_CONTENT_LENGTH
from datetime import datetime
import os
import logging
cert_bp = Blueprint('certs', __name__, url_prefix='/api/certificates')
logger = logging.getLogger(__name__)
def login_required_api(f):
"""API version of login required"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated', 'success': False}), 401
return f(*args, **kwargs)
return decorated_function
@cert_bp.route('', methods=['GET'])
@login_required_api
def list_certificates():
"""Get all certificates"""
try:
certs = Certificate.query.order_by(Certificate.created_at.desc()).all()
return jsonify({
'success': True,
'certificates': [{
'id': c.id,
'name': c.name,
'common_name': c.common_name,
'expires_at': c.expires_at.isoformat() if c.expires_at else None,
'created_at': c.created_at.isoformat(),
'vhost_count': len(c.vhosts),
'is_expired': c.expires_at < datetime.utcnow() if c.expires_at else False
} for c in certs]
})
except Exception as e:
logger.error(f"[CERTS] Error listing: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@cert_bp.route('/<int:cert_id>', methods=['GET'])
@login_required_api
def get_certificate(cert_id):
"""Get certificate details"""
try:
cert = Certificate.query.get(cert_id)
if not cert:
return jsonify({'error': 'Certificate not found', 'success': False}), 404
return jsonify({
'success': True,
'certificate': {
'id': cert.id,
'name': cert.name,
'common_name': cert.common_name,
'subject_alt_names': cert.get_san_list(),
'issued_at': cert.issued_at.isoformat() if cert.issued_at else None,
'expires_at': cert.expires_at.isoformat() if cert.expires_at else None,
'created_at': cert.created_at.isoformat(),
'vhosts': [{
'id': v.id,
'name': v.name,
'hostname': v.hostname
} for v in cert.vhosts]
}
})
except Exception as e:
logger.error(f"[CERTS] Error getting cert {cert_id}: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@cert_bp.route('', methods=['POST'])
@login_required_api
def upload_certificate():
"""Upload SSL certificate (PEM format)"""
try:
# Check if file provided
if 'cert_file' not in request.files:
return jsonify({'error': 'No certificate file provided', 'success': False}), 400
file = request.files['cert_file']
cert_name = request.form.get('name', '').strip()
if not file or file.filename == '':
return jsonify({'error': 'No selected file', 'success': False}), 400
if not cert_name:
return jsonify({'error': 'Certificate name required', 'success': False}), 400
# Check if name already exists
if Certificate.query.filter_by(name=cert_name).first():
return jsonify({'error': 'Certificate name already exists', 'success': False}), 400
# Read file content
cert_content = file.read().decode('utf-8')
# Validate and parse certificate
cert_data = parse_certificate(cert_content)
if 'error' in cert_data:
return jsonify({'error': cert_data['error'], 'success': False}), 400
# Create certificate record
cert = Certificate(
name=cert_name,
cert_content=cert_content,
cert_only=cert_data.get('cert_only'),
key_only=cert_data.get('key_only'),
common_name=cert_data.get('common_name'),
issued_at=cert_data.get('issued_at'),
expires_at=cert_data.get('expires_at')
)
if cert_data.get('subject_alt_names'):
cert.set_san_list(cert_data['subject_alt_names'])
db.session.add(cert)
db.session.flush()
# Save cert file to disk
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert_name}.pem')
if not save_cert_file(cert_path, cert_content):
db.session.rollback()
return jsonify({'error': 'Failed to save certificate file', 'success': False}), 500
db.session.commit()
logger.info(f"[CERTS] Uploaded certificate '{cert_name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'id': cert.id,
'name': cert.name,
'message': 'Certificate uploaded successfully'
}), 201
except Exception as e:
db.session.rollback()
logger.error(f"[CERTS] Error uploading cert: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@cert_bp.route('/<int:cert_id>', methods=['DELETE'])
@login_required_api
def delete_certificate(cert_id):
"""Delete certificate"""
try:
cert = Certificate.query.get(cert_id)
if not cert:
return jsonify({'error': 'Certificate not found', 'success': False}), 404
# Check if certificate is in use
if cert.vhosts:
vhost_names = [v.name for v in cert.vhosts]
return jsonify({
'error': f'Certificate is in use by vhosts: {", ".join(vhost_names)}',
'success': False
}), 400
cert_name = cert.name
# Delete file from disk
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert_name}.pem')
delete_cert_file(cert_path)
# Delete from database
db.session.delete(cert)
db.session.commit()
logger.info(f"[CERTS] Deleted certificate '{cert_name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': f'Certificate {cert_name} deleted successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"[CERTS] Error deleting cert: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@cert_bp.route('/<int:cert_id>/export', methods=['GET'])
@login_required_api
def export_certificate(cert_id):
"""Download certificate in PEM format"""
try:
cert = Certificate.query.get(cert_id)
if not cert:
return jsonify({'error': 'Certificate not found', 'success': False}), 404
# Read from disk
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert.name}.pem')
if not os.path.exists(cert_path):
return jsonify({'error': 'Certificate file not found', 'success': False}), 404
with open(cert_path, 'r') as f:
cert_content = f.read()
return jsonify({
'success': True,
'name': cert.name,
'content': cert_content
})
except Exception as e:
logger.error(f"[CERTS] Error exporting cert: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500

View File

@@ -1,126 +1,21 @@
from flask import Blueprint, render_template, request """Edit HAProxy configuration"""
import subprocess
from auth.auth_middleware import requires_auth
edit_bp = Blueprint('edit', __name__) from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for
from routes.auth_routes import login_required
from database.models import VirtualHost
import logging
@edit_bp.route('/edit', methods=['GET', 'POST']) edit_bp = Blueprint('edit', __name__, url_prefix='/edit')
@requires_auth logger = logging.getLogger(__name__)
@edit_bp.route('/')
@login_required
def edit_haproxy_config(): def edit_haproxy_config():
if request.method == 'POST': """Edit configuration page"""
edited_config = request.form.get('haproxy_config', '')
action = request.form.get('action', 'check')
print(f"[EDIT] POST action: {action}", flush=True)
try: try:
with open('/etc/haproxy/haproxy.cfg', 'w') as f: vhosts = VirtualHost.query.all()
f.write(edited_config) return render_template('edit.html', vhosts=vhosts)
print(f"[EDIT] Configuration saved successfully", flush=True)
except Exception as e: except Exception as e:
print(f"[EDIT] Error writing config: {e}", flush=True) logger.error(f"[EDIT] Error: {e}", flush=True)
return render_template( return render_template('edit.html', vhosts=[], error=str(e))
'edit.html',
config_content=edited_config,
check_output=f"Error writing configuration: {e}",
check_level="danger"
)
check_output = ""
check_level = "success"
try:
result = subprocess.run(
['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=10
)
check_output = (result.stdout or '').strip()
if result.returncode == 0:
if not check_output:
check_output = "Configuration file is valid"
check_level = "success"
if "Warning" in check_output or "Warnings" in check_output:
check_level = "warning"
check_output = f"{check_output}"
else:
check_output = f"{check_output}"
print(f"[EDIT] Config validation: SUCCESS", flush=True)
else:
if not check_output:
check_output = f"Check failed with return code {result.returncode}"
check_output = f"{check_output}"
check_level = "danger"
print(f"[EDIT] Config validation: FAILED - {check_output}", flush=True)
except subprocess.TimeoutExpired:
check_output = "✗ Configuration check timed out"
check_level = "danger"
print(f"[EDIT] Config validation: TIMEOUT", flush=True)
except Exception as e:
check_output = f"✗ Error checking config: {e}"
check_level = "danger"
print(f"[EDIT] Config validation ERROR: {e}", flush=True)
if action == "save" and check_level == "success":
print(f"[EDIT] Attempting HAProxy restart...", flush=True)
try:
restart_result = subprocess.run(
['pkill', '-f', 'haproxy'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=10
)
if restart_result.returncode == 0 or 'No such process' in restart_result.stdout:
check_output += "\n\n✓ HAProxy restart signal sent successfully"
check_output += "\n(supervisord will restart the process)"
print(f"[EDIT] HAProxy restart successful", flush=True)
else:
check_output += f"\n\n⚠ Restart returned code {restart_result.returncode}"
if restart_result.stdout:
check_output += f"\nOutput: {restart_result.stdout}"
check_level = "warning"
print(f"[EDIT] Restart warning: {restart_result.stdout}", flush=True)
except subprocess.TimeoutExpired:
check_output += "\n\n⚠ Restart command timed out"
check_level = "warning"
print(f"[EDIT] Restart TIMEOUT", flush=True)
except Exception as e:
check_output += f"\n\n⚠ Restart error: {e}"
check_level = "warning"
print(f"[EDIT] Restart ERROR: {e}", flush=True)
print(f"[EDIT] Returning check_level={check_level}, output length={len(check_output)}", flush=True)
return render_template(
'edit.html',
config_content=edited_config,
check_output=check_output,
check_level=check_level
)
# GET request - load current config
try:
with open('/etc/haproxy/haproxy.cfg', 'r') as f:
config_content = f.read()
print(f"[EDIT] Config loaded successfully ({len(config_content)} bytes)", flush=True)
except FileNotFoundError:
config_content = "# HAProxy configuration file not found\n# Please create /etc/haproxy/haproxy.cfg\n"
print(f"[EDIT] Config file not found", flush=True)
except PermissionError:
config_content = "# Permission denied reading HAProxy configuration file\n"
print(f"[EDIT] Permission denied reading config", flush=True)
except Exception as e:
config_content = f"# Error reading config: {e}\n"
print(f"[EDIT] Error reading config: {e}", flush=True)
return render_template('edit.html', config_content=config_content)

View File

@@ -1,251 +1,39 @@
from flask import Blueprint, render_template, request """Main routes - Dashboard, Home"""
import subprocess
from auth.auth_middleware import requires_auth from flask import Blueprint, render_template, redirect, url_for, session
from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends from database.models import VirtualHost
from routes.auth_routes import login_required
import logging
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
logger = logging.getLogger(__name__)
def reload_haproxy():
"""Reload HAProxy by killing it - supervisord restarts automatically"""
try:
# Validate config first
result = subprocess.run(
['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=10
)
if result.returncode != 0: @main_bp.route('/')
return False, f"Config validation failed: {result.stdout}"
# Kill haproxy - supervisord will restart it automatically
result = subprocess.run(
['pkill', '-f', 'haproxy'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=10
)
if result.returncode == 0 or 'No such process' in result.stdout:
print("[HAPROXY] Process killed, supervisord will restart", flush=True)
return True, "HAProxy restarted successfully"
else:
print(f"[HAPROXY] pkill failed: {result.stdout}", flush=True)
return False, f"pkill failed: {result.stdout}"
except Exception as e:
print(f"[HAPROXY] Error: {e}", flush=True)
return False, f"Error: {str(e)}"
@main_bp.route('/', methods=['GET', 'POST'])
@requires_auth
def index(): def index():
if request.method == 'POST': """Dashboard - list vhosts"""
# Frontend IP i port if 'user_id' not in session:
frontend_ip = request.form['frontend_ip'] return redirect(url_for('auth.login'))
frontend_port = request.form['frontend_port']
frontend_hostname = request.form.get('frontend_hostname', '').strip()
lb_method = request.form['lb_method'] return render_template('dashboard.html')
protocol = request.form['protocol']
backend_name = request.form['backend_name']
# Header options
add_header = 'add_header' in request.form
header_name = request.form.get('header_name', '') if add_header else ''
header_value = request.form.get('header_value', '') if add_header else ''
# Server header removal @main_bp.route('/home')
del_server_header = 'del_server_header' in request.form @login_required
def home():
"""Home - alias for dashboard"""
return redirect(url_for('main.index'))
backend_ssl_redirect = 'backend_ssl_redirect' in request.form
ssl_redirect_backend_name = request.form.get('ssl_redirect_backend_name', '').strip() if backend_ssl_redirect else ''
ssl_redirect_port = request.form.get('ssl_redirect_port', '80') # ✅ POBIERA PORT Z FORMU
# Backend servers @main_bp.route('/display_logs')
backend_server_names = request.form.getlist('backend_server_names[]') @login_required
backend_server_ips = request.form.getlist('backend_server_ips[]') def display_logs():
backend_server_ports = request.form.getlist('backend_server_ports[]') """Display HAProxy logs"""
backend_server_maxconns = request.form.getlist('backend_server_maxconns[]') return render_template('logs.html')
# Custom ACL
add_custom_acl = 'add_custom_acl' in request.form
custom_acl_name = request.form.get('custom_acl_name', '').strip() if add_custom_acl else ''
custom_acl_type = request.form.get('custom_acl_type', 'path_beg') if add_custom_acl else ''
custom_acl_value = request.form.get('custom_acl_value', '').strip() if add_custom_acl else ''
custom_acl_action = request.form.get('custom_acl_action', 'route') if add_custom_acl else ''
custom_acl_backend = request.form.get('custom_acl_backend', '').strip() if add_custom_acl else ''
custom_acl_redirect_url = request.form.get('custom_acl_redirect_url', '').strip() if add_custom_acl else ''
# SSL @main_bp.route('/display_haproxy_stats')
use_ssl = 'ssl_checkbox' in request.form @login_required
ssl_cert_path = request.form.get('ssl_cert_path', '/app/ssl/haproxy-configurator.pem') def display_haproxy_stats():
https_redirect = 'ssl_redirect_checkbox' in request.form """Display HAProxy statistics"""
return render_template('statistics.html')
# DOS Protection
is_dos = 'add_dos' in request.form
ban_duration = request.form.get('ban_duration', '30m')
limit_requests = request.form.get('limit_requests', '100')
# Forward For
forward_for = 'forward_for_check' in request.form
# SQL Injection
sql_injection_check = 'sql_injection_check' in request.form
# XSS
is_xss = 'xss_check' in request.form
# Remote uploads
is_remote_upload = 'remote_uploads_check' in request.form
# Webshells
is_webshells = 'webshells_check' in request.form
# Path-based redirects (legacy)
add_path_based = 'add_path_based' in request.form
redirect_domain_name = request.form.get('redirect_domain_name', '')
root_redirect = request.form.get('root_redirect', '')
redirect_to = request.form.get('redirect_to', '')
# Forbidden paths (legacy)
is_forbidden_path = 'add_acl_path' in request.form
forbidden_name = request.form.get('forbidden_name', '')
allowed_ip = request.form.get('allowed_ip', '')
forbidden_path = request.form.get('forbidden_path', '')
# Build backend_servers list
backend_servers = []
for i in range(len(backend_server_ips)):
name = backend_server_names[i] if i < len(backend_server_names) else f"server{i+1}"
ip = backend_server_ips[i] if i < len(backend_server_ips) else ''
port = backend_server_ports[i] if i < len(backend_server_ports) else ''
maxconn = backend_server_maxconns[i] if i < len(backend_server_maxconns) else None
if ip and port:
backend_servers.append((name, ip, port, maxconn))
# Health checks
health_check = False
health_check_link = ""
if protocol == 'http':
health_check = 'health_check' in request.form
if health_check:
health_check_link = request.form.get('health_check_link', '/')
health_check_tcp = False
if protocol == 'tcp':
health_check_tcp = 'health_check2' in request.form
# Sticky session
sticky_session = False
sticky_session_type = ""
if 'sticky_session' in request.form:
sticky_session = True
sticky_session_type = request.form.get('sticky_session_type', 'cookie')
# Legacy ACL (unused, kept for compatibility)
is_acl = False
acl_name = ''
acl_action = ''
acl_backend_name = ''
# Frontend name (None - will be generated)
frontend_name = None
# Call update_haproxy_config
message = update_haproxy_config(
frontend_name=frontend_name,
frontend_ip=frontend_ip,
frontend_port=frontend_port,
lb_method=lb_method,
protocol=protocol,
backend_name=backend_name,
backend_servers=backend_servers,
health_check=health_check,
health_check_tcp=health_check_tcp,
health_check_link=health_check_link,
sticky_session=sticky_session,
add_header=add_header,
header_name=header_name,
header_value=header_value,
sticky_session_type=sticky_session_type,
is_acl=is_acl,
acl_name=acl_name,
acl_action=acl_action,
acl_backend_name=acl_backend_name,
use_ssl=use_ssl,
ssl_cert_path=ssl_cert_path,
https_redirect=https_redirect,
is_dos=is_dos,
ban_duration=ban_duration,
limit_requests=limit_requests,
forward_for=forward_for,
is_forbidden_path=is_forbidden_path,
forbidden_name=forbidden_name,
allowed_ip=allowed_ip,
forbidden_path=forbidden_path,
sql_injection_check=sql_injection_check,
is_xss=is_xss,
is_remote_upload=is_remote_upload,
add_path_based=add_path_based,
redirect_domain_name=redirect_domain_name,
root_redirect=root_redirect,
redirect_to=redirect_to,
is_webshells=is_webshells,
del_server_header=del_server_header,
backend_ssl_redirect=backend_ssl_redirect,
ssl_redirect_backend_name=ssl_redirect_backend_name,
ssl_redirect_port=ssl_redirect_port,
frontend_hostname=frontend_hostname,
add_custom_acl=add_custom_acl,
custom_acl_name=custom_acl_name,
custom_acl_type=custom_acl_type,
custom_acl_value=custom_acl_value,
custom_acl_action=custom_acl_action,
custom_acl_backend=custom_acl_backend,
custom_acl_redirect_url=custom_acl_redirect_url
)
# ===== DETERMINE MESSAGE TYPE =====
message_type = "success" # Default
# Check for ERROR conditions
if "error" in message.lower():
message_type = "danger"
elif "failed" in message.lower():
message_type = "danger"
elif "already exists" in message.lower():
message_type = "danger"
elif "cannot add" in message.lower():
message_type = "danger"
# SUCCESS conditions
elif "configuration updated successfully" in message.lower():
message_type = "success"
elif "backend added to existing" in message.lower():
message_type = "success"
# ===== RELOAD HAPROXY (JEŚLI SUCCESS) =====
if message_type == "success":
reload_ok, reload_msg = reload_haproxy()
if reload_ok:
message = message + "" + reload_msg
message_type = "success"
else:
message = message + "" + reload_msg
message_type = "warning"
return render_template('index.html',
message=message,
message_type=message_type)
# GET request - display stats
frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends()
return render_template('index.html',
frontend_count=frontend_count,
backend_count=backend_count,
acl_count=acl_count,
layer7_count=layer7_count,
layer4_count=layer4_count)

211
routes/user_routes.py Normal file
View File

@@ -0,0 +1,211 @@
"""User management routes - Admin only"""
from flask import Blueprint, request, jsonify, session
from functools import wraps
from database import db
from database.models import User
from routes.auth_routes import admin_required
import logging
user_bp = Blueprint('users', __name__, url_prefix='/api/users')
logger = logging.getLogger(__name__)
def api_admin_required(f):
"""Decorator for API admin requirement"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated'}), 401
user = User.query.get(session['user_id'])
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
return f(*args, **kwargs)
return decorated_function
@user_bp.route('', methods=['GET'])
@api_admin_required
def list_users():
"""List all users"""
try:
users = User.query.all()
return jsonify({
'success': True,
'users': [{
'id': u.id,
'username': u.username,
'is_admin': u.is_admin,
'created_at': u.created_at.isoformat(),
'last_login': u.last_login.isoformat() if u.last_login else None
} for u in users]
})
except Exception as e:
logger.error(f"[USERS] Error listing users: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@user_bp.route('', methods=['POST'])
@api_admin_required
def create_user():
"""Create new user"""
try:
data = request.json
username = data.get('username', '').strip()
password = data.get('password', '').strip()
is_admin = data.get('is_admin', False)
if not username or not password:
return jsonify({'error': 'Username and password required', 'success': False}), 400
if len(username) < 3:
return jsonify({'error': 'Username must be at least 3 characters', 'success': False}), 400
if len(password) < 6:
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
# Check if exists
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username already exists', 'success': False}), 400
user = User(username=username, is_admin=is_admin)
user.set_password(password)
db.session.add(user)
db.session.commit()
logger.info(f"[USERS] Created user '{username}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'id': user.id,
'username': user.username,
'is_admin': user.is_admin,
'message': 'User created successfully'
}), 201
except Exception as e:
db.session.rollback()
logger.error(f"[USERS] Error creating user: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@user_bp.route('/<int:user_id>', methods=['PUT'])
@api_admin_required
def update_user(user_id):
"""Update user"""
try:
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found', 'success': False}), 404
data = request.json
# Don't allow self-demotion
if user.id == session['user_id'] and 'is_admin' in data and not data['is_admin']:
return jsonify({'error': 'Cannot remove admin role from yourself', 'success': False}), 400
if 'is_admin' in data:
user.is_admin = data['is_admin']
if 'password' in data and data['password']:
password = data['password'].strip()
if len(password) < 6:
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
user.set_password(password)
db.session.commit()
logger.info(f"[USERS] Updated user '{user.username}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': 'User updated successfully',
'id': user.id,
'username': user.username,
'is_admin': user.is_admin
})
except Exception as e:
db.session.rollback()
logger.error(f"[USERS] Error updating user: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@user_bp.route('/<int:user_id>', methods=['DELETE'])
@api_admin_required
def delete_user(user_id):
"""Delete user"""
try:
# Don't allow self-deletion
if user_id == session['user_id']:
return jsonify({'error': 'Cannot delete your own account', 'success': False}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found', 'success': False}), 404
username = user.username
db.session.delete(user)
db.session.commit()
logger.info(f"[USERS] Deleted user '{username}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': f'User {username} deleted successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"[USERS] Error deleting user: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@user_bp.route('/<int:user_id>/change-password', methods=['POST'])
def change_password(user_id):
"""Change own password"""
try:
# Check if logged in
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated', 'success': False}), 401
# Can only change own password, or admin can change others
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found', 'success': False}), 404
is_admin = session.get('is_admin', False)
is_own_account = session['user_id'] == user_id
if not is_own_account and not is_admin:
return jsonify({'error': 'Forbidden', 'success': False}), 403
data = request.json
# If changing own password, require old password
if is_own_account:
old_password = data.get('old_password', '').strip()
if not user.check_password(old_password):
return jsonify({'error': 'Current password is incorrect', 'success': False}), 400
new_password = data.get('new_password', '').strip()
if len(new_password) < 6:
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
user.set_password(new_password)
db.session.commit()
logger.info(f"[USERS] Password changed for '{user.username}'", flush=True)
return jsonify({
'success': True,
'message': 'Password changed successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"[USERS] Error changing password: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500

523
routes/vhost_routes.py Normal file
View File

@@ -0,0 +1,523 @@
"""Virtual Host Management - RESTful API"""
from flask import Blueprint, request, jsonify, session
from functools import wraps
from database import db
from database.models import VirtualHost, BackendServer, ConfigHistory, Certificate
from utils.config_generator import generate_haproxy_config, reload_haproxy
from datetime import datetime
import logging
vhost_bp = Blueprint('vhosts', __name__, url_prefix='/api/vhosts')
logger = logging.getLogger(__name__)
def login_required_api(f):
"""API version of login required"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated', 'success': False}), 401
return f(*args, **kwargs)
return decorated_function
@vhost_bp.route('', methods=['GET'])
@login_required_api
def list_vhosts():
"""Get all virtual hosts"""
try:
vhosts = VirtualHost.query.order_by(VirtualHost.created_at.desc()).all()
return jsonify({
'success': True,
'vhosts': [{
'id': v.id,
'name': v.name,
'hostname': v.hostname,
'frontend_ip': v.frontend_ip,
'frontend_port': v.frontend_port,
'protocol': v.protocol,
'use_ssl': v.use_ssl,
'lb_method': v.lb_method,
'enabled': v.enabled,
'backend_count': len(v.backend_servers),
'created_at': v.created_at.isoformat(),
'updated_at': v.updated_at.isoformat() if v.updated_at else None
} for v in vhosts]
})
except Exception as e:
logger.error(f"[VHOSTS] Error listing: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/<int:vhost_id>', methods=['GET'])
@login_required_api
def get_vhost(vhost_id):
"""Get single vhost with backend servers"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
return jsonify({
'success': True,
'vhost': {
'id': vhost.id,
'name': vhost.name,
'hostname': vhost.hostname,
'description': vhost.description,
'frontend_ip': vhost.frontend_ip,
'frontend_port': vhost.frontend_port,
'protocol': vhost.protocol,
'use_ssl': vhost.use_ssl,
'certificate_id': vhost.certificate_id,
'ssl_redirect': vhost.ssl_redirect,
'ssl_redirect_port': vhost.ssl_redirect_port,
'lb_method': vhost.lb_method,
'dos_protection': vhost.dos_protection,
'dos_ban_duration': vhost.dos_ban_duration,
'dos_limit_requests': vhost.dos_limit_requests,
'sql_injection_check': vhost.sql_injection_check,
'xss_check': vhost.xss_check,
'webshell_check': vhost.webshell_check,
'add_custom_header': vhost.add_custom_header,
'custom_header_name': vhost.custom_header_name,
'custom_header_value': vhost.custom_header_value,
'del_server_header': vhost.del_server_header,
'forward_for': vhost.forward_for,
'enabled': vhost.enabled,
'backend_servers': [{
'id': bs.id,
'name': bs.name,
'ip_address': bs.ip_address,
'port': bs.port,
'maxconn': bs.maxconn,
'weight': bs.weight,
'health_check': bs.health_check,
'health_check_path': bs.health_check_path,
'enabled': bs.enabled
} for bs in vhost.backend_servers]
}
})
except Exception as e:
logger.error(f"[VHOSTS] Error getting vhost {vhost_id}: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('', methods=['POST'])
@login_required_api
def create_vhost():
"""Create new virtual host"""
try:
data = request.json
# Validate required fields
required = ['name', 'hostname', 'frontend_port']
for field in required:
if not data.get(field):
return jsonify({'error': f'{field} is required', 'success': False}), 400
# Check if name already exists
if VirtualHost.query.filter_by(name=data['name']).first():
return jsonify({'error': 'VHost name already exists', 'success': False}), 400
# Create vhost
vhost = VirtualHost(
name=data['name'].strip(),
hostname=data['hostname'].strip(),
description=data.get('description', '').strip(),
frontend_ip=data.get('frontend_ip', '0.0.0.0'),
frontend_port=int(data['frontend_port']),
protocol=data.get('protocol', 'http'),
use_ssl=data.get('use_ssl', False),
certificate_id=data.get('certificate_id'),
ssl_redirect=data.get('ssl_redirect', False),
ssl_redirect_port=int(data.get('ssl_redirect_port', 80)),
lb_method=data.get('lb_method', 'roundrobin'),
dos_protection=data.get('dos_protection', False),
dos_ban_duration=data.get('dos_ban_duration', '30m'),
dos_limit_requests=int(data.get('dos_limit_requests', 100)),
sql_injection_check=data.get('sql_injection_check', False),
xss_check=data.get('xss_check', False),
webshell_check=data.get('webshell_check', False),
add_custom_header=data.get('add_custom_header', False),
custom_header_name=data.get('custom_header_name', ''),
custom_header_value=data.get('custom_header_value', ''),
del_server_header=data.get('del_server_header', False),
forward_for=data.get('forward_for', True),
enabled=data.get('enabled', True)
)
db.session.add(vhost)
db.session.flush()
# Add backend servers if provided
if data.get('backend_servers'):
for bs_data in data['backend_servers']:
backend = BackendServer(
vhost_id=vhost.id,
name=bs_data.get('name', f'server_{bs_data.get("ip_address")}'),
ip_address=bs_data['ip_address'],
port=int(bs_data['port']),
maxconn=bs_data.get('maxconn'),
weight=int(bs_data.get('weight', 1)),
health_check=bs_data.get('health_check', False),
health_check_path=bs_data.get('health_check_path', '/'),
enabled=bs_data.get('enabled', True)
)
db.session.add(backend)
db.session.commit()
# Save config history
config_history = ConfigHistory(
config_content=generate_haproxy_config(),
change_type='vhost_create',
vhost_id=vhost.id,
user_id=session['user_id'],
description=f"Created VHost: {vhost.name}"
)
db.session.add(config_history)
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[VHOSTS] Created VHost '{vhost.name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'id': vhost.id,
'name': vhost.name,
'message': 'VHost created successfully'
}), 201
except Exception as e:
db.session.rollback()
logger.error(f"[VHOSTS] Error creating vhost: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/<int:vhost_id>', methods=['PUT'])
@login_required_api
def update_vhost(vhost_id):
"""Update virtual host"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
data = request.json
# Update basic fields
if 'name' in data:
# Check if name is taken
existing = VirtualHost.query.filter_by(name=data['name']).filter(VirtualHost.id != vhost_id).first()
if existing:
return jsonify({'error': 'VHost name already exists', 'success': False}), 400
vhost.name = data['name'].strip()
if 'hostname' in data:
vhost.hostname = data['hostname'].strip()
if 'description' in data:
vhost.description = data['description'].strip()
if 'frontend_ip' in data:
vhost.frontend_ip = data['frontend_ip']
if 'frontend_port' in data:
vhost.frontend_port = int(data['frontend_port'])
if 'protocol' in data:
vhost.protocol = data['protocol']
if 'use_ssl' in data:
vhost.use_ssl = data['use_ssl']
if 'certificate_id' in data:
vhost.certificate_id = data['certificate_id']
if 'ssl_redirect' in data:
vhost.ssl_redirect = data['ssl_redirect']
if 'ssl_redirect_port' in data:
vhost.ssl_redirect_port = int(data['ssl_redirect_port'])
if 'lb_method' in data:
vhost.lb_method = data['lb_method']
# Security settings
if 'dos_protection' in data:
vhost.dos_protection = data['dos_protection']
if 'dos_ban_duration' in data:
vhost.dos_ban_duration = data['dos_ban_duration']
if 'dos_limit_requests' in data:
vhost.dos_limit_requests = int(data['dos_limit_requests'])
if 'sql_injection_check' in data:
vhost.sql_injection_check = data['sql_injection_check']
if 'xss_check' in data:
vhost.xss_check = data['xss_check']
if 'webshell_check' in data:
vhost.webshell_check = data['webshell_check']
# Header settings
if 'add_custom_header' in data:
vhost.add_custom_header = data['add_custom_header']
if 'custom_header_name' in data:
vhost.custom_header_name = data['custom_header_name']
if 'custom_header_value' in data:
vhost.custom_header_value = data['custom_header_value']
if 'del_server_header' in data:
vhost.del_server_header = data['del_server_header']
if 'forward_for' in data:
vhost.forward_for = data['forward_for']
if 'enabled' in data:
vhost.enabled = data['enabled']
vhost.updated_at = datetime.utcnow()
db.session.commit()
# Save config history
config_history = ConfigHistory(
config_content=generate_haproxy_config(),
change_type='vhost_edit',
vhost_id=vhost.id,
user_id=session['user_id'],
description=f"Updated VHost: {vhost.name}"
)
db.session.add(config_history)
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[VHOSTS] Updated VHost '{vhost.name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': 'VHost updated successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"[VHOSTS] Error updating vhost {vhost_id}: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/<int:vhost_id>', methods=['DELETE'])
@login_required_api
def delete_vhost(vhost_id):
"""Delete virtual host"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
vhost_name = vhost.name
# Save config history before deletion
config_history = ConfigHistory(
config_content=generate_haproxy_config(),
change_type='vhost_delete',
vhost_id=vhost.id,
user_id=session['user_id'],
description=f"Deleted VHost: {vhost_name}"
)
db.session.add(config_history)
# Delete vhost (cascades to backend servers)
db.session.delete(vhost)
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[VHOSTS] Deleted VHost '{vhost_name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': f'VHost {vhost_name} deleted successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"[VHOSTS] Error deleting vhost {vhost_id}: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/<int:vhost_id>/toggle', methods=['POST'])
@login_required_api
def toggle_vhost(vhost_id):
"""Toggle vhost enabled/disabled"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
vhost.enabled = not vhost.enabled
vhost.updated_at = datetime.utcnow()
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[VHOSTS] Toggled VHost '{vhost.name}' to {vhost.enabled} by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'enabled': vhost.enabled,
'message': f"VHost {'enabled' if vhost.enabled else 'disabled'}"
})
except Exception as e:
db.session.rollback()
logger.error(f"[VHOSTS] Error toggling vhost {vhost_id}: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
# ===== BACKEND SERVERS =====
@vhost_bp.route('/<int:vhost_id>/servers', methods=['GET'])
@login_required_api
def get_vhost_servers(vhost_id):
"""Get all backend servers for vhost"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
return jsonify({
'success': True,
'servers': [{
'id': bs.id,
'name': bs.name,
'ip_address': bs.ip_address,
'port': bs.port,
'maxconn': bs.maxconn,
'weight': bs.weight,
'health_check': bs.health_check,
'health_check_path': bs.health_check_path,
'enabled': bs.enabled
} for bs in vhost.backend_servers]
})
except Exception as e:
logger.error(f"[SERVERS] Error getting servers: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/<int:vhost_id>/servers', methods=['POST'])
@login_required_api
def add_backend_server(vhost_id):
"""Add backend server to vhost"""
try:
vhost = VirtualHost.query.get(vhost_id)
if not vhost:
return jsonify({'error': 'VHost not found', 'success': False}), 404
data = request.json
if not data.get('ip_address') or not data.get('port'):
return jsonify({'error': 'IP address and port required', 'success': False}), 400
server = BackendServer(
vhost_id=vhost_id,
name=data.get('name', f"server_{data['ip_address']}"),
ip_address=data['ip_address'],
port=int(data['port']),
maxconn=data.get('maxconn'),
weight=int(data.get('weight', 1)),
health_check=data.get('health_check', False),
health_check_path=data.get('health_check_path', '/'),
enabled=data.get('enabled', True)
)
db.session.add(server)
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[SERVERS] Added server to VHost '{vhost.name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'id': server.id,
'message': 'Backend server added'
}), 201
except Exception as e:
db.session.rollback()
logger.error(f"[SERVERS] Error adding server: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/servers/<int:server_id>', methods=['PUT'])
@login_required_api
def update_backend_server(server_id):
"""Update backend server"""
try:
server = BackendServer.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found', 'success': False}), 404
data = request.json
if 'name' in data:
server.name = data['name']
if 'ip_address' in data:
server.ip_address = data['ip_address']
if 'port' in data:
server.port = int(data['port'])
if 'maxconn' in data:
server.maxconn = data['maxconn']
if 'weight' in data:
server.weight = int(data['weight'])
if 'health_check' in data:
server.health_check = data['health_check']
if 'health_check_path' in data:
server.health_check_path = data['health_check_path']
if 'enabled' in data:
server.enabled = data['enabled']
server.updated_at = datetime.utcnow()
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[SERVERS] Updated server '{server.name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': 'Backend server updated'
})
except Exception as e:
db.session.rollback()
logger.error(f"[SERVERS] Error updating server: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500
@vhost_bp.route('/servers/<int:server_id>', methods=['DELETE'])
@login_required_api
def delete_backend_server(server_id):
"""Delete backend server"""
try:
server = BackendServer.query.get(server_id)
if not server:
return jsonify({'error': 'Server not found', 'success': False}), 404
server_name = server.name
vhost_id = server.vhost_id
db.session.delete(server)
db.session.commit()
# Reload HAProxy
reload_haproxy()
logger.info(f"[SERVERS] Deleted server '{server_name}' by {session.get('username')}", flush=True)
return jsonify({
'success': True,
'message': f'Backend server {server_name} deleted'
})
except Exception as e:
db.session.rollback()
logger.error(f"[SERVERS] Error deleting server: {e}", flush=True)
return jsonify({'error': str(e), 'success': False}), 500

219
static/js/cert_manager.js Normal file
View File

@@ -0,0 +1,219 @@
/**
* Certificate Manager - Upload, List, Delete
*/
let currentCertId = null;
document.addEventListener('DOMContentLoaded', function() {
loadCertificates();
document.getElementById('uploadBtn').addEventListener('click', uploadCertificate);
document.getElementById('exportBtn').addEventListener('click', exportCertificate);
});
function loadCertificates() {
fetch('/api/certificates')
.then(r => r.json())
.then(data => {
if (data.success) {
renderCertificates(data.certificates);
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => console.error('Error loading certificates:', e));
}
function renderCertificates(certs) {
const tbody = document.getElementById('certsList');
if (certs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">No certificates uploaded yet</td></tr>';
return;
}
tbody.innerHTML = certs.map(c => {
const expiresDate = c.expires_at ? new Date(c.expires_at) : null;
const isExpired = c.is_expired;
const expiresText = expiresDate ? formatDate(expiresDate) : 'Unknown';
return `
<tr ${isExpired ? 'class="table-danger"' : ''}>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td><code>${escapeHtml(c.common_name || 'N/A')}</code></td>
<td>
${isExpired ? '<span class="badge bg-danger">EXPIRED</span>' : ''}
<small>${expiresText}</small>
</td>
<td>
${c.vhost_count > 0
? `<span class="badge bg-info">${c.vhost_count}</span>`
: '<span class="text-muted">Not used</span>'}
</td>
<td>
${isExpired
? '<span class="badge bg-danger">Expired</span>'
: '<span class="badge bg-success">Valid</span>'}
</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewDetails(${c.id})">
<i class="bi bi-eye"></i> View
</button>
${c.vhost_count === 0 ? `
<button class="btn btn-sm btn-danger" onclick="deleteCert(${c.id}, '${escapeHtml(c.name)}')">
<i class="bi bi-trash"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
function uploadCertificate() {
const name = document.getElementById('cert_name').value;
const file = document.getElementById('cert_file').files[0];
if (!name || !file) {
showAlert('Certificate name and file are required', 'warning');
return;
}
const formData = new FormData();
formData.append('name', name);
formData.append('cert_file', file);
fetch('/api/certificates', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
document.getElementById('uploadForm').reset();
loadCertificates();
showAlert('Certificate uploaded successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function viewDetails(certId) {
currentCertId = certId;
fetch(`/api/certificates/${certId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
const c = data.certificate;
document.getElementById('detailsName').textContent = c.name;
const san = c.subject_alt_names && c.subject_alt_names.length > 0
? c.subject_alt_names.join(', ')
: 'No SAN';
const vhosts = c.vhosts && c.vhosts.length > 0
? c.vhosts.map(v => `<li>${v.name} (${v.hostname})</li>`).join('')
: '<li class="text-muted">Not used</li>';
const detailsHTML = `
<table class="table table-sm">
<tr>
<th>Common Name</th>
<td><code>${escapeHtml(c.common_name || 'N/A')}</code></td>
</tr>
<tr>
<th>Subject Alt Names</th>
<td><code>${escapeHtml(san)}</code></td>
</tr>
<tr>
<th>Issued</th>
<td>${c.issued_at ? formatDate(new Date(c.issued_at)) : 'Unknown'}</td>
</tr>
<tr>
<th>Expires</th>
<td>
${c.expires_at ? formatDate(new Date(c.expires_at)) : 'Unknown'}
${new Date(c.expires_at) < new Date() ? '<span class="badge bg-danger ms-2">EXPIRED</span>' : ''}
</td>
</tr>
<tr>
<th>Created</th>
<td>${formatDate(new Date(c.created_at))}</td>
</tr>
</table>
<h6>Used by VHosts:</h6>
<ul>${vhosts}</ul>
`;
document.getElementById('detailsContent').innerHTML = detailsHTML;
new bootstrap.Modal(document.getElementById('detailsModal')).show();
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function exportCertificate() {
fetch(`/api/certificates/${currentCertId}/export`)
.then(r => r.json())
.then(data => {
if (data.success) {
// Create download link
const blob = new Blob([data.content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${data.name}.pem`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function deleteCert(certId, name) {
if (!confirm(`Delete certificate '${name}'? This cannot be undone.`)) return;
fetch(`/api/certificates/${certId}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.success) {
loadCertificates();
showAlert('Certificate deleted successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function showAlert(message, type) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.card').parentElement.insertBefore(alertDiv, document.querySelector('.card'));
setTimeout(() => alertDiv.remove(), 5000);
}
function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function escapeHtml(text) {
const map = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'};
return text.replace(/[&<>"']/g, m => map[m]);
}

156
static/js/user_manager.js Normal file
View File

@@ -0,0 +1,156 @@
/**
* User Management UI
*/
let currentEditUserId = null;
document.addEventListener('DOMContentLoaded', function() {
loadUsers();
document.getElementById('createUserBtn').addEventListener('click', createUser);
document.getElementById('updateUserBtn').addEventListener('click', updateUser);
});
function loadUsers() {
fetch('/api/users')
.then(r => r.json())
.then(data => {
if (data.success) {
renderUsers(data.users);
}
})
.catch(e => console.error('Error loading users:', e));
}
function renderUsers(users) {
const tbody = document.getElementById('usersList');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No users found</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr>
<td><strong>${escapeHtml(user.username)}</strong></td>
<td>
${user.is_admin ? '<span class="badge bg-danger">Admin</span>' : '<span class="badge bg-secondary">User</span>'}
</td>
<td><small class="text-muted">${formatDate(user.created_at)}</small></td>
<td><small class="text-muted">${user.last_login ? formatDate(user.last_login) : 'Never'}</small></td>
<td>
<button class="btn btn-sm btn-primary" onclick="editUser(${user.id}, '${escapeHtml(user.username)}', ${user.is_admin})">
<i class="bi bi-pencil"></i>
</button>
${user.id !== getUserId() ? `
<button class="btn btn-sm btn-danger" onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')">
<i class="bi bi-trash"></i>
</button>
` : ''}
</td>
</tr>
`).join('');
}
function createUser() {
const username = document.getElementById('newUsername').value;
const password = document.getElementById('newPassword').value;
const isAdmin = document.getElementById('newIsAdmin').checked;
fetch('/api/users', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username, password, is_admin: isAdmin })
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('newUserModal')).hide();
document.getElementById('newUserForm').reset();
loadUsers();
showAlert('User created successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function editUser(userId, username, isAdmin) {
currentEditUserId = userId;
document.getElementById('editUsername').textContent = username;
document.getElementById('editIsAdmin').checked = isAdmin;
new bootstrap.Modal(document.getElementById('editUserModal')).show();
}
function updateUser() {
const password = document.getElementById('editPassword').value;
const isAdmin = document.getElementById('editIsAdmin').checked;
const body = { is_admin: isAdmin };
if (password) body.password = password;
fetch(`/api/users/${currentEditUserId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('editUserModal')).hide();
loadUsers();
showAlert('User updated successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function deleteUser(userId, username) {
if (!confirm(`Delete user '${username}'?`)) return;
fetch(`/api/users/${userId}`, {method: 'DELETE'})
.then(r => r.json())
.then(data => {
if (data.success) {
loadUsers();
showAlert('User deleted successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function showAlert(message, type) {
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.card-body').prepend(alert);
setTimeout(() => alert.remove(), 5000);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function escapeHtml(text) {
const map = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'};
return text.replace(/[&<>"']/g, m => map[m]);
}
function getUserId() {
// Extract from current user endpoint
let userId = null;
fetch('/api/current-user')
.then(r => r.json())
.then(data => { userId = data.id; });
return userId;
}

285
static/js/vhost_manager.js Normal file
View File

@@ -0,0 +1,285 @@
/**
* VHost Manager - Frontend CRUD Logic
*/
let currentEditVHostId = null;
document.addEventListener('DOMContentLoaded', function() {
loadVHosts();
// Event listeners
document.getElementById('createVHostBtn').addEventListener('click', createVHost);
document.getElementById('updateVHostBtn').addEventListener('click', updateVHost);
document.getElementById('addServerBtn').addEventListener('click', addServerInput);
// Event delegation for remove buttons
document.getElementById('backendServersContainer').addEventListener('click', function(e) {
if (e.target.classList.contains('remove-server')) {
e.target.closest('.backend-server').remove();
}
});
});
function loadVHosts() {
fetch('/api/vhosts')
.then(r => r.json())
.then(data => {
if (data.success) {
renderVHosts(data.vhosts);
updateStats(data.vhosts);
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => console.error('Error loading vhosts:', e));
}
function updateStats(vhosts) {
document.getElementById('total_vhosts').textContent = vhosts.length;
document.getElementById('enabled_vhosts').textContent = vhosts.filter(v => v.enabled).length;
document.getElementById('disabled_vhosts').textContent = vhosts.filter(v => !v.enabled).length;
document.getElementById('ssl_vhosts').textContent = vhosts.filter(v => v.use_ssl).length;
}
function renderVHosts(vhosts) {
const tbody = document.getElementById('vhostsList');
if (vhosts.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">No VHosts created yet</td></tr>';
return;
}
tbody.innerHTML = vhosts.map(v => `
<tr>
<td>
<strong>${escapeHtml(v.name)}</strong>
${v.use_ssl ? '<span class="badge bg-success ms-2">SSL</span>' : ''}
</td>
<td>${escapeHtml(v.hostname)}</td>
<td>
<code>${v.frontend_ip}:${v.frontend_port}</code>
</td>
<td><span class="badge bg-info">${v.protocol.toUpperCase()}</span></td>
<td>
<span class="badge bg-secondary">${v.backend_count} servers</span>
</td>
<td>
<button class="btn btn-sm ${v.enabled ? 'btn-success' : 'btn-warning'}"
onclick="toggleVHost(${v.id}, ${v.enabled})">
<i class="bi bi-${v.enabled ? 'power' : 'hourglass'}"></i>
${v.enabled ? 'Enabled' : 'Disabled'}
</button>
</td>
<td>
<button class="btn btn-sm btn-primary" onclick="editVHost(${v.id})">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="btn btn-sm btn-danger" onclick="deleteVHost(${v.id}, '${escapeHtml(v.name)}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function createVHost() {
const name = document.getElementById('vhost_name').value;
const hostname = document.getElementById('vhost_hostname').value;
const port = document.getElementById('vhost_port').value;
const protocol = document.getElementById('vhost_protocol').value;
const lb_method = document.getElementById('vhost_lb_method').value;
const use_ssl = document.getElementById('vhost_ssl').checked;
// Get backend servers
const servers = [];
document.querySelectorAll('.backend-server').forEach(el => {
const ip = el.querySelector('.backend-ip').value;
const port = el.querySelector('.backend-port').value;
if (ip && port) {
servers.push({ ip_address: ip, port: parseInt(port) });
}
});
if (servers.length === 0) {
showAlert('At least one backend server is required', 'warning');
return;
}
const payload = {
name, hostname,
frontend_port: parseInt(port),
protocol, lb_method, use_ssl,
backend_servers: servers
};
fetch('/api/vhosts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('newVHostModal')).hide();
document.getElementById('newVHostForm').reset();
document.getElementById('backendServersContainer').innerHTML = `
<div class="backend-server mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-6 mb-2">
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
</div>
<div class="col-md-6 mb-2">
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
</div>
</div>
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
</div>
`;
loadVHosts();
showAlert('VHost created successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function editVHost(vhostId) {
currentEditVHostId = vhostId;
fetch(`/api/vhosts/${vhostId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
const v = data.vhost;
document.getElementById('editVHostName').textContent = v.name;
const formContent = `
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">VHost Name</label>
<input type="text" class="form-control" id="edit_name" value="${escapeHtml(v.name)}">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Hostname</label>
<input type="text" class="form-control" id="edit_hostname" value="${escapeHtml(v.hostname)}">
</div>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="edit_enabled" ${v.enabled ? 'checked' : ''}>
<label class="form-check-label" for="edit_enabled">Enabled</label>
</div>
<hr>
<h6>Backend Servers</h6>
<div id="editServersContainer">
${v.backend_servers.map(bs => `
<div class="backend-server mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-6 mb-2">
<input type="text" class="form-control backend-ip" value="${bs.ip_address}" required>
</div>
<div class="col-md-6 mb-2">
<input type="number" class="form-control backend-port" value="${bs.port}" required>
</div>
</div>
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
</div>
`).join('')}
</div>
`;
document.getElementById('editFormContent').innerHTML = formContent;
new bootstrap.Modal(document.getElementById('editVHostModal')).show();
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function updateVHost() {
const name = document.getElementById('edit_name').value;
const hostname = document.getElementById('edit_hostname').value;
const enabled = document.getElementById('edit_enabled').checked;
const payload = { name, hostname, enabled };
fetch(`/api/vhosts/${currentEditVHostId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('editVHostModal')).hide();
loadVHosts();
showAlert('VHost updated successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function toggleVHost(vhostId, isEnabled) {
fetch(`/api/vhosts/${vhostId}/toggle`, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
loadVHosts();
showAlert(data.message, 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function deleteVHost(vhostId, name) {
if (!confirm(`Delete VHost '${name}'? This cannot be undone.`)) return;
fetch(`/api/vhosts/${vhostId}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.success) {
loadVHosts();
showAlert('VHost deleted successfully', 'success');
} else {
showAlert(data.error, 'danger');
}
})
.catch(e => showAlert(e.message, 'danger'));
}
function addServerInput() {
const container = document.getElementById('backendServersContainer');
const newServer = document.createElement('div');
newServer.className = 'backend-server mb-3 p-3 border rounded';
newServer.innerHTML = `
<div class="row">
<div class="col-md-6 mb-2">
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
</div>
<div class="col-md-6 mb-2">
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
</div>
</div>
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
`;
container.appendChild(newServer);
}
function showAlert(message, type) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.card:first-of-type').parentElement.insertBefore(alertDiv, document.querySelector('.card:first-of-type'));
setTimeout(() => alertDiv.remove(), 5000);
}
function escapeHtml(text) {
const map = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'};
return text.replace(/[&<>"']/g, m => map[m]);
}

View File

@@ -1,13 +1,11 @@
[supervisord] [supervisord]
nodaemon=true nodaemon=true
user=root
loglevel=info
logfile=/var/log/supervisor/supervisord.log logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
[unix_http_server] [unix_http_server]
file=/var/run/supervisor.sock file=/var/run/supervisor.sock
chmod=0700
[supervisorctl] [supervisorctl]
serverurl=unix:///var/run/supervisor.sock serverurl=unix:///var/run/supervisor.sock
@@ -15,26 +13,35 @@ serverurl=unix:///var/run/supervisor.sock
[rpcinterface:supervisor] [rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[program:haproxy] # ===== FLASK APPLICATION =====
command=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg [program:flask]
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/haproxy.err.log
stdout_logfile=/var/log/haproxy.log
priority=100
stopasgroup=true
killasgroup=true
startsecs=10
stopwaitsecs=10
[program:flask_app]
command=python /app/app.py command=python /app/app.py
directory=/app directory=/app
autostart=true autostart=true
autorestart=true autorestart=true
stderr_logfile=/var/log/supervisor/flask_app.err.log stdout_logfile=/var/log/supervisor/flask.log
stdout_logfile=/var/log/supervisor/flask_app.out.log stderr_logfile=/var/log/supervisor/flask_error.log
priority=999
environment=FLASK_APP=/app/app.py,FLASK_ENV=production,PYTHONUNBUFFERED=1
startsecs=10
stopasgroup=true stopasgroup=true
stopsignal=TERM
priority=999
environment=PYTHONUNBUFFERED=1,FLASK_APP=app.py,FLASK_ENV=production
# ===== HAPROXY =====
[program:haproxy]
command=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/haproxy.log
stderr_logfile=/var/log/supervisor/haproxy_error.log
stopasgroup=true
priority=998
# ===== LOG ROTATION =====
[program:logrotate]
command=/bin/bash -c "while true; do sleep 86400; logrotate /etc/logrotate.d/haproxy 2>/dev/null || true; done"
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/logrotate.log
stderr_logfile=/var/log/supervisor/logrotate_error.log
stopasgroup=true
priority=997

16
templates/403.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Access Denied{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 style="font-size: 72px; color: #dc3545;">403</h1>
<h2>Access Denied</h2>
<p class="text-muted">You don't have permission to access this resource.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Go Home
</a>
</div>
</div>
{% endblock %}

16
templates/404.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Page Not Found{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
<h2>Page Not Found</h2>
<p class="text-muted">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Go Home
</a>
</div>
</div>
{% endblock %}

16
templates/500.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Server Error{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
<h2>Internal Server Error</h2>
<p class="text-muted">Something went wrong on our end.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Go Home
</a>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% set active_page = active_page|default('') %} {% set active_page = active_page|default('') %}
<!doctype html> <!doctype html>
<html lang="pl" data-bs-theme="dark"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -15,46 +15,190 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
<style>
.navbar-brand {
font-weight: 700;
font-size: 1.25rem;
}
.user-menu {
min-width: 200px;
}
.menu-divider {
margin: 0.5rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
</head> </head>
<body class="bg-body"> <body class="bg-body">
<header class="header1" id="header1"> <!-- NAVBAR -->
<div class="container d-flex align-items-center justify-content-between flex-wrap gap-2 py-2"> <header class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<a href="{{ url_for('home') }}" class="d-flex align-items-center text-decoration-none logo text-reset"> <div class="container-fluid px-4">
<h3 class="m-0 d-flex align-items-center gap-2"> <!-- Logo -->
<i class="fas fa-globe"></i><span>HAProxy Configurator</span> <a href="{% if session.get('user_id') %}{{ url_for('main.index') }}{% else %}{{ url_for('auth.login') }}{% endif %}" class="navbar-brand d-flex align-items-center gap-2">
</h3> <i class="fas fa-globe"></i>
<span>HAProxy Manager</span>
</a> </a>
<nav class="menu d-flex align-items-center gap-1 flex-wrap">
<a href="{{ url_for('home') }}" class="menu-link {{ 'active' if request.path.startswith('/home') else '' }}"><i class="bi bi-speedometer"></i> Dashboard</a>
<a href="{{ url_for('main.index') }}" class="menu-link {{ 'active' if request.path == '/' else '' }}"><i class="bi bi-plus-circle"></i> Add FE/BE</a> <!-- Toggle Button (Mobile) -->
<a href="{{ url_for('edit.edit_haproxy_config') }}" class="menu-link {{ 'active' if request.path.startswith('/edit') else '' }}"><i class="bi bi-pencil-square"></i> Edit Configuration</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<a href="{{ url_for('display_logs') }}" class="menu-link {{ 'active' if request.path.startswith('/logs') else '' }}"><i class="bi bi-shield-lock"></i> Logs</a> <span class="navbar-toggler-icon"></span>
<a href="{{ url_for('display_haproxy_stats') }}" class="menu-link {{ 'active' if request.path.startswith('/statistics') else '' }}"><i class="bi bi-graph-up-arrow"></i> Stats</a> </button>
<a href="http://{{ request.host.split(':')[0] }}:8404/stats" class="menu-link" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> HAProxy Stats</a>
<!-- Navigation Menu -->
<div class="collapse navbar-collapse" id="navbarNav">
{% if session.get('user_id') %}
<nav class="navbar-nav ms-auto d-flex align-items-center gap-3">
<!-- Menu Links -->
<a href="{{ url_for('main.index') }}" class="nav-link {{ 'active' if request.path == '/' or request.path.startswith('/home') else '' }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<a href="{{ url_for('main.index') }}" class="nav-link {{ 'active' if request.path.startswith('/add') else '' }}">
<i class="bi bi-plus-circle"></i> Add VHost
</a>
<a href="{{ url_for('edit.edit_haproxy_config') }}" class="nav-link {{ 'active' if request.path.startswith('/edit') else '' }}">
<i class="bi bi-pencil-square"></i> Edit Config
</a>
<a href="{{ url_for('main.display_logs') }}" class="nav-link {{ 'active' if request.path.startswith('/logs') else '' }}">
<i class="bi bi-shield-lock"></i> Logs
</a>
<a href="{{ url_for('main.display_haproxy_stats') }}" class="nav-link {{ 'active' if request.path.startswith('/statistics') else '' }}">
<i class="bi bi-graph-up-arrow"></i> Stats
</a>
<a href="http://{{ request.host.split(':')[0] }}:8404/stats" class="nav-link" target="_blank" rel="noopener">
<i class="bi bi-box-arrow-up-right"></i> HAProxy Stats
</a>
<!-- User Dropdown Menu -->
<div class="nav-item dropdown">
<button class="nav-link dropdown-toggle btn btn-link text-decoration-none d-flex align-items-center gap-2" id="userMenu" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle"></i>
<span class="d-none d-lg-inline">{{ session.get('username', 'User') }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end user-menu" aria-labelledby="userMenu">
<li>
<a class="dropdown-item" href="#" disabled>
<i class="bi bi-person"></i> {{ session.get('username', 'User') }}
</a>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Admin Only Menu Items -->
{% if session.get('is_admin') %}
<li>
<a class="dropdown-item" href="{{ url_for('main.index') }}">
<i class="bi bi-people"></i> User Management
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('main.index') }}">
<i class="bi bi-shield-check"></i> Certificates
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li>
<a class="dropdown-item text-warning" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</li>
</ul>
</div>
</nav> </nav>
{% else %}
<!-- Login Link (when not authenticated) -->
<nav class="navbar-nav ms-auto">
<a href="{{ url_for('auth.login') }}" class="nav-link">
<i class="bi bi-box-arrow-in-right"></i> Login
</a>
</nav>
{% endif %}
</div> </div>
</header> </div>
</header>
<main class="container py-4">
{% with messages = get_flashed_messages() %}{% if messages %}<div id="_flash_msgs" data-msgs="{{ messages|tojson }}"></div>{% endif %}{% endwith %} <!-- MAIN CONTENT -->
<main class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ 'check-circle' if category == 'success' else 'exclamation-circle' if category == 'danger' else 'info-circle' }}"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Breadcrumb -->
{% block breadcrumb %}{% endblock %} {% block breadcrumb %}{% endblock %}
<!-- Toast Container -->
<div id="toast-stack" class="toast-container position-fixed top-0 end-0 p-3"></div> <div id="toast-stack" class="toast-container position-fixed top-0 end-0 p-3"></div>
<!-- Page Content -->
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="app-footer border-top">
<div class="container d-flex flex-wrap justify-content-between align-items-center py-3 small text-muted"> <!-- FOOTER -->
<span>© 2025 HAProxy Configurator</span> <footer class="app-footer border-top border-secondary bg-dark mt-5">
<span class="d-flex align-items-center gap-2"> <div class="container-fluid px-4 py-3">
<i class="bi bi-code-slash"></i> <div class="row align-items-center">
<span>Based on: <a href="https://github.com/alonz22/haproxy-dashboard">This project</a> | by @linuxiarz.pl </span> <div class="col-md-6 small text-muted">
</span> <p class="mb-2">
© 2025 <strong>HAProxy Configurator & Manager</strong>
</p>
<p class="mb-0">
<i class="bi bi-info-circle"></i>
Powerful web-based HAProxy configuration management system
</p>
</div> </div>
</footer> <div class="col-md-6 text-md-end small text-muted text-start">
<p class="mb-2">
<i class="bi bi-code-slash"></i>
Built with <strong>Flask</strong> + <strong>SQLAlchemy</strong> + <strong>Bootstrap 5</strong>
</p>
<p class="mb-0">
Based on: <a href="https://github.com/alonz22/haproxy-dashboard" target="_blank" class="text-decoration-none" rel="noopener">Original Project</a>
| Maintained by <strong>@linuxiarz.pl</strong>
</p>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <!-- SCRIPTS -->
{% block scripts %}{% endblock %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{% block page_js %}{% endblock %} <script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body> {% block scripts %}{% endblock %}
{% block page_js %}{% endblock %}
<script>
/**
* Auto-dismiss alerts after 5 seconds
*/
document.addEventListener('DOMContentLoaded', function() {
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(alert => {
setTimeout(() => {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}, 5000);
});
});
</script>
</body>
</html> </html>

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}HAProxy • Certificate Manager{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
<li class="breadcrumb-item active">SSL Certificates</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-lock"></i> SSL Certificates</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-cloud-upload"></i> Upload Certificate
</button>
</div>
<!-- Certificates Table -->
<div class="card">
<div class="card-header bg-dark">
<h5 class="mb-0"><i class="bi bi-list-check"></i> Certificates List</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="certsTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Common Name (CN)</th>
<th>Expires</th>
<th>Used By</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="certsList">
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">Upload SSL Certificate</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="uploadForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Certificate Name *</label>
<input type="text" class="form-control" id="cert_name" required>
<small class="text-muted">e.g., example-com-2025</small>
</div>
<div class="mb-3">
<label class="form-label">PEM Certificate File * <i class="bi bi-info-circle" title="Combined certificate + key in PEM format"></i></label>
<input type="file" class="form-control" id="cert_file" accept=".pem,.crt,.cert,.key" required>
<small class="text-muted">
Supported formats: PEM (combined cert+key), .crt, .cert, .key
</small>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Supported Formats:</strong>
<ul class="mb-0 mt-2">
<li>Single file with both certificate and private key (PEM)</li>
<li>PKCS#12 (.p12) - will be converted</li>
<li>Separate .crt and .key files - upload combined</li>
</ul>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="uploadBtn">Upload</button>
</div>
</div>
</div>
</div>
<!-- Certificate Details Modal -->
<div class="modal fade" id="detailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title">Certificate Details: <span id="detailsName"></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="detailsContent"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" id="exportBtn">
<i class="bi bi-download"></i> Export PEM
</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/cert_manager.js') }}"></script>
{% endblock %}

191
templates/dashboard.html Normal file
View File

@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}HAProxy • Dashboard{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
<li class="breadcrumb-item active">VHosts</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-server"></i> Virtual Hosts</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newVHostModal">
<i class="bi bi-plus-lg"></i> Create VHost
</button>
</div>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total VHosts</h5>
<h3 id="total_vhosts">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Enabled</h5>
<h3 id="enabled_vhosts">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<h5 class="card-title">Disabled</h5>
<h3 id="disabled_vhosts">0</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">SSL Enabled</h5>
<h3 id="ssl_vhosts">0</h3>
</div>
</div>
</div>
</div>
<!-- VHosts Table -->
<div class="card">
<div class="card-header bg-dark">
<h5 class="mb-0"><i class="bi bi-list-check"></i> VHosts List</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="vhostsTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Bind</th>
<th>Protocol</th>
<th>Servers</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="vhostsList">
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- New VHost Modal -->
<div class="modal fade" id="newVHostModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">Create New VHost</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="newVHostForm">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">VHost Name *</label>
<input type="text" class="form-control" id="vhost_name" required>
<small class="text-muted">e.g., web-app-01</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Hostname *</label>
<input type="text" class="form-control" id="vhost_hostname" required>
<small class="text-muted">e.g., example.com</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Frontend Port *</label>
<input type="number" class="form-control" id="vhost_port" value="443" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Protocol</label>
<select class="form-select" id="vhost_protocol">
<option value="http">HTTP</option>
<option value="tcp">TCP</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Load Balancing Method</label>
<select class="form-select" id="vhost_lb_method">
<option value="roundrobin">Round Robin</option>
<option value="leastconn">Least Connections</option>
<option value="source">Source IP Hash</option>
<option value="uri">URI Hash</option>
</select>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="vhost_ssl">
<label class="form-check-label" for="vhost_ssl">
Enable SSL
</label>
</div>
<hr>
<h6>Backend Servers</h6>
<div id="backendServersContainer">
<div class="backend-server mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-6 mb-2">
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
</div>
<div class="col-md-6 mb-2">
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
</div>
</div>
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="addServerBtn">
<i class="bi bi-plus"></i> Add Server
</button>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="createVHostBtn">Create VHost</button>
</div>
</div>
</div>
</div>
<!-- Edit VHost Modal -->
<div class="modal fade" id="editVHostModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">Edit VHost: <span id="editVHostName"></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editVHostForm">
<div id="editFormContent"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="updateVHostBtn">Update VHost</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/vhost_manager.js') }}"></script>
{% endblock %}

132
templates/login.html Normal file
View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HAProxy Configurator - Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 400px;
background: white;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header i {
font-size: 48px;
color: #667eea;
margin-bottom: 10px;
}
.login-header h1 {
font-size: 28px;
color: #333;
margin: 10px 0 5px;
}
.login-header p {
color: #666;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-control {
border: 1px solid #ddd;
padding: 10px 15px;
border-radius: 5px;
font-size: 14px;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-login {
width: 100%;
padding: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
}
.btn-login:hover {
opacity: 0.95;
}
.alert {
font-size: 14px;
margin-bottom: 20px;
}
.default-creds {
background: #f8f9fa;
border-left: 4px solid #ffc107;
padding: 10px 15px;
border-radius: 4px;
margin-top: 20px;
font-size: 12px;
}
.default-creds strong {
color: #ff6b6b;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<i class="bi bi-shield-lock"></i>
<h1>HAProxy</h1>
<p>Configurator & Manager</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="username" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="password" required>
</div>
<button type="submit" class="btn btn-login">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
</button>
</form>
<div class="default-creds">
<i class="bi bi-info-circle me-1"></i>
<strong>Default credentials:</strong><br>
Username: <code>admin</code><br>
Password: <code>admin123</code>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% set active_page = "users" %}
{% block title %}HAProxy • User Management{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
<li class="breadcrumb-item active">Users</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-people me-2"></i>User Management</h5>
</div>
<div class="card-body">
<!-- New User Button -->
<button class="btn btn-success mb-3" data-bs-toggle="modal" data-bs-target="#newUserModal">
<i class="bi bi-person-plus me-1"></i>Add New User
</button>
<!-- Users Table -->
<div class="table-responsive">
<table class="table table-hover" id="usersTable">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersList">
<tr><td colspan="5" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- New User Modal -->
<div class="modal fade" id="newUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="newUserForm">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="newUsername" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="newPassword" required minlength="6">
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="newIsAdmin">
<label class="form-check-label" for="newIsAdmin">
Admin privileges
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="createUserBtn">Create</button>
</div>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit User: <span id="editUsername"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editUserForm">
<div class="mb-3">
<label class="form-label">New Password (leave blank to keep current)</label>
<input type="password" class="form-control" id="editPassword" minlength="6">
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="editIsAdmin">
<label class="form-check-label" for="editIsAdmin">
Admin privileges
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="updateUserBtn">Update</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/user_manager.js') }}"></script>
{% endblock %}

123
utils/cert_manager.py Normal file
View File

@@ -0,0 +1,123 @@
"""Certificate Management Utilities - Parse, Validate, Save"""
import os
import re
from datetime import datetime
from cryptography import x509
from cryptography.hazmat.backends import default_backend
import logging
logger = logging.getLogger(__name__)
def parse_certificate(pem_content):
"""
Parse PEM certificate and extract metadata
Returns: dict with 'common_name', 'expires_at', 'subject_alt_names', 'error'
"""
try:
# Split cert and key if combined
cert_only = None
key_only = None
# Extract certificate
cert_match = re.search(
r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
pem_content,
re.DOTALL
)
if not cert_match:
return {'error': 'No valid certificate found in PEM file'}
cert_only = cert_match.group(0)
# Extract private key if present
key_match = re.search(
r'-----BEGIN (?:RSA )?PRIVATE KEY-----.*?-----END (?:RSA )?PRIVATE KEY-----',
pem_content,
re.DOTALL
)
if key_match:
key_only = key_match.group(0)
# Parse certificate
try:
cert = x509.load_pem_x509_certificate(
cert_only.encode(),
default_backend()
)
except Exception as e:
return {'error': f'Invalid certificate: {str(e)}'}
# Extract common name
common_name = None
try:
common_name = cert.subject.get_attributes_for_oid(
x509.oid.NameOID.COMMON_NAME
)[0].value
except:
pass
# Extract Subject Alternative Names (SAN)
subject_alt_names = []
try:
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
for name in san_ext.value:
if isinstance(name, x509.DNSName):
subject_alt_names.append(name.value)
except:
pass
# Extract dates
issued_at = cert.not_valid_before if cert.not_valid_before else None
expires_at = cert.not_valid_after if cert.not_valid_after else None
result = {
'common_name': common_name,
'cert_only': cert_only,
'key_only': key_only,
'issued_at': issued_at,
'expires_at': expires_at,
'subject_alt_names': subject_alt_names
}
logger.info(f"[CERT] Parsed certificate: CN={common_name}, expires={expires_at}", flush=True)
return result
except Exception as e:
logger.error(f"[CERT] Error parsing certificate: {e}", flush=True)
return {'error': f'Failed to parse certificate: {str(e)}'}
def save_cert_file(cert_path, cert_content):
"""Save certificate to disk"""
try:
# Create directory if not exists
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
# Write file
with open(cert_path, 'w') as f:
f.write(cert_content)
# Set permissions (owner only can read)
os.chmod(cert_path, 0o600)
logger.info(f"[CERT] Saved certificate file: {cert_path}", flush=True)
return True
except Exception as e:
logger.error(f"[CERT] Error saving certificate file: {e}", flush=True)
return False
def delete_cert_file(cert_path):
"""Delete certificate file from disk"""
try:
if os.path.exists(cert_path):
os.remove(cert_path)
logger.info(f"[CERT] Deleted certificate file: {cert_path}", flush=True)
return True
except Exception as e:
logger.error(f"[CERT] Error deleting certificate file: {e}", flush=True)
return False

222
utils/config_generator.py Normal file
View File

@@ -0,0 +1,222 @@
"""HAProxy Config Generator - Build config from database"""
import os
import subprocess
import shutil
from datetime import datetime
from database import db
from database.models import VirtualHost, Certificate
from config.settings import HAPROXY_CONFIG_PATH, HAPROXY_BACKUP_DIR, HAPROXY_STATS_PORT # ✅ TUTAJ!
import logging
logger = logging.getLogger(__name__)
def generate_haproxy_config():
"""Generate HAProxy config from database"""
config_lines = []
# ===== GLOBAL SECTION =====
config_lines.extend([
"# HAProxy Configuration - Auto-generated by HAProxy Manager",
f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
"global",
" log stdout local0",
" log stdout local1 notice",
" chroot /var/lib/haproxy",
" stats socket /run/haproxy/admin.sock mode 660 level admin",
" stats timeout 30s",
" user haproxy",
" group haproxy",
" daemon",
" maxconn 4096",
" tune.ssl.default-dh-param 2048",
"",
])
# ===== DEFAULTS SECTION =====
config_lines.extend([
"defaults",
" log global",
" mode http",
" option httplog",
" option dontlognull",
" option http-server-close",
" option forwardfor except 127.0.0.0/8",
" option redispatch",
" retries 3",
" timeout connect 5000",
" timeout client 50000",
" timeout server 50000",
" errorfile 400 /etc/haproxy/errors/400.http",
" errorfile 403 /etc/haproxy/errors/403.http",
" errorfile 408 /etc/haproxy/errors/408.http",
" errorfile 500 /etc/haproxy/errors/500.http",
" errorfile 502 /etc/haproxy/errors/502.http",
" errorfile 503 /etc/haproxy/errors/503.http",
" errorfile 504 /etc/haproxy/errors/504.http",
"",
])
# ===== STATS FRONTEND (HARDCODED :8404) =====
config_lines.extend([
"# HAProxy Statistics - Hardcoded on port 8404",
"frontend stats",
f" bind *:{HAPROXY_STATS_PORT}",
" stats enable",
" stats admin if TRUE",
" stats uri /stats",
" stats refresh 30s",
"",
])
# ===== FRONTENDS & BACKENDS FROM DATABASE =====
vhosts = VirtualHost.query.filter_by(enabled=True).all()
for vhost in vhosts:
# Skip if no backend servers
if not vhost.backend_servers:
logger.warning(f"[CONFIG] VHost '{vhost.name}' has no backend servers, skipping")
continue
# ===== FRONTEND =====
config_lines.append(f"# VHost: {vhost.name}")
config_lines.append(f"frontend {vhost.name}")
config_lines.append(f" description {vhost.hostname}")
config_lines.append(f" bind {vhost.frontend_ip}:{vhost.frontend_port}")
# SSL config
if vhost.use_ssl and vhost.certificate_id:
cert = Certificate.query.get(vhost.certificate_id)
if cert:
# Certificate file path
cert_path = f"/app/uploads/certificates/{cert.name}.pem"
config_lines.append(f" ssl crt {cert_path}")
config_lines.append(f" mode {vhost.protocol}")
config_lines.append(f" option httplog")
# Custom headers
if vhost.del_server_header:
config_lines.append(" http-response del-header Server")
if vhost.add_custom_header and vhost.custom_header_name:
config_lines.append(f" http-response add-header {vhost.custom_header_name} {vhost.custom_header_value}")
if vhost.forward_for:
config_lines.append(" option forwardfor except 127.0.0.0/8")
# Security rules
if vhost.dos_protection:
config_lines.extend([
f" stick-table type ip size 100k expire {vhost.dos_ban_duration} store http_req_rate(10s)",
f" http-request track-sc0 src",
f" http-request deny if {{ sc_http_req_rate(0) gt {vhost.dos_limit_requests} }}",
])
if vhost.sql_injection_check:
config_lines.append(" # SQL Injection protection enabled")
if vhost.xss_check:
config_lines.append(" # XSS protection enabled")
if vhost.webshell_check:
config_lines.append(" # WebShell protection enabled")
config_lines.append(f" default_backend {vhost.name}_backend")
config_lines.append("")
# ===== BACKEND =====
config_lines.append(f"backend {vhost.name}_backend")
config_lines.append(f" balance {vhost.lb_method}")
config_lines.append(f" option httpchk GET / HTTP/1.1\\r\\nHost:\\ www")
# Backend servers
for i, server in enumerate(vhost.backend_servers, 1):
if not server.enabled:
continue
server_line = f" server {server.name} {server.ip_address}:{server.port}"
if server.weight != 1:
server_line += f" weight {server.weight}"
if server.maxconn:
server_line += f" maxconn {server.maxconn}"
if server.health_check:
server_line += f" check inter 5000 rise 2 fall 3"
config_lines.append(server_line)
config_lines.append("")
return "\n".join(config_lines)
def save_haproxy_config(config_content):
"""Save config to file"""
try:
# Create backup
if os.path.exists(HAPROXY_CONFIG_PATH):
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = os.path.join(HAPROXY_BACKUP_DIR, f'haproxy_backup_{timestamp}.cfg')
shutil.copy2(HAPROXY_CONFIG_PATH, backup_path)
logger.info(f"[CONFIG] Backup created: {backup_path}", flush=True)
# Write new config
with open(HAPROXY_CONFIG_PATH, 'w') as f:
f.write(config_content)
logger.info(f"[CONFIG] Config saved to {HAPROXY_CONFIG_PATH}", flush=True)
return True
except Exception as e:
logger.error(f"[CONFIG] Error saving config: {e}", flush=True)
return False
def reload_haproxy():
"""Reload HAProxy service"""
try:
# Generate config
config_content = generate_haproxy_config()
# Test config syntax
result = subprocess.run(
['haproxy', '-c', '-f', HAPROXY_CONFIG_PATH],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
logger.error(f"[CONFIG] HAProxy syntax error: {result.stderr}", flush=True)
return False
# Save config
if not save_haproxy_config(config_content):
return False
# Reload HAProxy
result = subprocess.run(
['systemctl', 'reload', 'haproxy'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("[CONFIG] HAProxy reloaded successfully", flush=True)
return True
else:
logger.error(f"[CONFIG] HAProxy reload failed: {result.stderr}", flush=True)
return False
except subprocess.TimeoutExpired:
logger.error("[CONFIG] HAProxy reload timeout", flush=True)
return False
except Exception as e:
logger.error(f"[CONFIG] Error reloading HAProxy: {e}", flush=True)
return False