diff --git a/.dockerignore b/.dockerignore index c3140a4..13463c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,28 @@ __pycache__ *.pyc +*.pyo +*.egg-info .git .gitignore .env -*.log -.pytest_cache -venv/ +.env.local .vscode +.idea +.pytest_cache +.coverage +htmlcov +dist +build +*.log +venv/ +env/ +.DS_Store +*.db-journal +*.swp +*.swo +*~ +.temp +instance/app.db +uploads/ +backups/ +logs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d1dbca --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index be16eda..f5e38a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ venv logs/* __pycache__ -config/* \ No newline at end of file +config/auth/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 674791f..3f2a143 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,49 +1,89 @@ FROM python:3.14-rc-trixie +# ===== ENV VARIABLES ===== ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 -# Install dependencies -RUN apt-get update && apt-get install -y \ +LABEL maintainer="HAProxy Manager" \ + version="2.0" \ + description="HAProxy Configuration Manager with SQLAlchemy" + +# ===== INSTALL SYSTEM DEPENDENCIES ===== +RUN apt-get update && apt-get install -y --no-install-recommends \ + # HAProxy & SSL haproxy \ - supervisor \ openssl \ + ca-certificates \ + \ + # Supervisord + supervisor \ + \ + # Utilities curl \ - && rm -rf /var/lib/apt/lists/* + wget \ + git \ + vim \ + \ + # Development (for cryptography) + libssl-dev \ + libffi-dev \ + python3-dev \ + build-essential \ + \ + # Cleanup + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean +# ===== WORKDIR ===== WORKDIR /app -# Copy requirements and install +# ===== COPY & INSTALL PYTHON REQUIREMENTS ===== 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 log_parser.py . + +# Copy directory structure +COPY config/ config/ +COPY database/ database/ COPY routes/ routes/ COPY utils/ utils/ COPY auth/ auth/ COPY templates/ templates/ -COPY static/ /app/static/ +COPY static/ static/ - -# Create directories -RUN mkdir -p /app/config/auth \ - && mkdir -p /app/config/ssl \ +# ===== CREATE REQUIRED DIRECTORIES ===== +RUN mkdir -p /app/instance \ + && mkdir -p /app/uploads/certificates \ + && mkdir -p /app/backups \ + && mkdir -p /app/logs \ + && mkdir -p /etc/haproxy \ && mkdir -p /etc/supervisor/conf.d \ && 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 entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -f https://localhost:5000 --insecure 2>/dev/null || exit 1 +# ===== SET PERMISSIONS ===== +RUN chmod +x /entrypoint.sh && \ + chmod 755 /app && \ + chmod -R 755 /app/uploads \ + chmod -R 755 /app/backups \ + chmod -R 755 /app/logs +# ===== EXPOSE PORTS ===== 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"] diff --git a/README.md b/README.md index e69de29..569f58f 100644 --- a/README.md +++ b/README.md @@ -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!) diff --git a/app.py b/app.py index bd27ef4..445ccad 100644 --- a/app.py +++ b/app.py @@ -1,196 +1,262 @@ +""" +HAProxy Configurator - Main Application +SQLAlchemy + Flask-SQLAlchemy Integration +""" + import os import sys import ssl 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.edit_routes import edit_bp +from routes.auth_routes import auth_bp +from routes.user_routes import user_bp +from auth.auth_middleware import setup_auth, login_required 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 +from routes.vhost_routes import vhost_bp +from routes.cert_routes import cert_bp + +# ===== BASE DIRECTORY ===== 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( __name__, static_folder=os.path.join(BASE_DIR, '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): - CONFIG_DIR = CONFIG_DIR_ENV -elif os.path.exists(CONFIG_DIR_DOCKER): - CONFIG_DIR = CONFIG_DIR_DOCKER -elif os.path.exists(CONFIG_DIR_LOCAL): - CONFIG_DIR = CONFIG_DIR_LOCAL -else: - CONFIG_DIR = CONFIG_DIR_DOCKER +# ===== LOAD CONFIGURATION ===== +app.config.from_object('config.settings') +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) +app.config['SESSION_COOKIE_SECURE'] = True +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -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) -os.makedirs(os.path.dirname(SSL_INI), exist_ok=True) +# ===== INITIALIZE DATABASE ===== +print("[APP] Initializing database...", flush=True) +db.init_app(app) +migrate.init_app(app, db) +init_db(app) +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(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 private_key_path = None ssl_context = None try: - config2 = configparser.ConfigParser() - config2.read(SSL_INI) - if config2.has_section('ssl'): - certificate_path = config2.get('ssl', 'certificate_path') - private_key_path = config2.get('ssl', 'private_key_path') + config_ssl = configparser.ConfigParser() + config_ssl.read(SSL_INI) + + if config_ssl.has_section('ssl'): + certificate_path = config_ssl.get('ssl', 'certificate_path') + private_key_path = config_ssl.get('ssl', 'private_key_path') + + if os.path.exists(certificate_path) and os.path.exists(private_key_path): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path) + 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) - sys.exit(1) - - if not os.path.exists(certificate_path): - print(f"[APP] Certificate not found: {certificate_path}", flush=True) - sys.exit(1) - - if not os.path.exists(private_key_path): - print(f"[APP] Private key not found: {private_key_path}", flush=True) - sys.exit(1) - - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) - ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path) - print(f"[APP] SSL context loaded", flush=True) + except Exception as e: - print(f"[APP] SSL error: {e}", flush=True) - sys.exit(1) + print(f"[APP] SSL warning (non-critical): {e}", flush=True) + + +# ===== 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') +@login_required def display_haproxy_stats(): - haproxy_stats = fetch_haproxy_stats() - parsed_stats = parse_haproxy_stats(haproxy_stats) - return render_template('statistics.html', stats=parsed_stats) + """Display HAProxy statistics""" + try: + haproxy_stats = fetch_haproxy_stats() + parsed_stats = parse_haproxy_stats(haproxy_stats) + return render_template('statistics.html', stats=parsed_stats) + except Exception as e: + print(f"[STATS] Error: {e}", flush=True) + return render_template('statistics.html', stats={}, error=str(e)) -@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}") + +@app.route('/dashboard') +@login_required +def dashboard(): + """Dashboard - Overview of vhosts and status""" + from database.models import VirtualHost + try: + vhosts = VirtualHost.query.all() + return render_template('dashboard.html', vhosts=vhosts) + except Exception as e: + print(f"[DASHBOARD] Error: {e}", flush=True) + return render_template('dashboard.html', vhosts=[], error=str(e)) + + +# ===== ERROR HANDLERS ===== + +@app.errorhandler(404) +def page_not_found(error): + """404 error handler""" + return render_template('404.html'), 404 + + +@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 + } + + +# ===== APPLICATION CONTEXT ===== + +@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""" + with app.app_context(): + from database import init_db + init_db(app) + print("[CLI] Database initialized successfully") + + +@app.cli.command() +def create_admin(): + """Create admin user""" + import getpass + username = input("Username: ") + password = getpass.getpass("Password: ") - 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: - return render_template('logs.html', - logs=[], - total_logs=0, - error_message=f"Error parsing logs: {str(e)}") - -@app.route('/api/logs', methods=['POST']) -def api_get_logs(): - """API endpoint for paginated and filtered logs""" - try: - log_file_path = '/var/log/haproxy.log' - - 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: - print(f"[API] Error: {e}", flush=True) - return jsonify({'error': str(e), 'success': False}), 500 + 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") -@app.route('/home') -def home(): - frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends() - return render_template('home.html', - frontend_count=frontend_count, - backend_count=backend_count, - acl_count=acl_count, - layer7_count=layer7_count, - layer4_count=layer4_count) +@app.cli.command() +def import_config(): + """Import existing haproxy.cfg to database""" + from database.migration import parse_existing_haproxy_config + + config_path = HAPROXY_CONFIG_PATH + count = parse_existing_haproxy_config(config_path) + print(f"[CLI] Successfully imported {count} vhosts from {config_path}") + + +# ===== MAIN ENTRY POINT ===== 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) + + app.run( + host='::', + port=5000, + ssl_context=ssl_context, + debug=DEBUG, + use_reloader=DEBUG + ) diff --git a/auth/auth_middleware.py b/auth/auth_middleware.py index 8262d29..9a449f5 100644 --- a/auth/auth_middleware.py +++ b/auth/auth_middleware.py @@ -1,53 +1,39 @@ -import os -import functools -from flask import request, Response -import configparser +"""Auth middleware - Updated for database""" -# Docker paths -CONFIG_DIR = './config' -AUTH_CFG = os.path.join(CONFIG_DIR, 'auth', 'auth.cfg') +from functools import wraps +from flask import session, redirect, url_for +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 -BASIC_AUTH_USERNAME = "admin" -BASIC_AUTH_PASSWORD = "admin" - -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 login_required(f): + """Require login for view""" + @wraps(f) def decorated_function(*args, **kwargs): - auth = request.authorization - if not auth or not check_auth(auth.username, auth.password): - return authenticate() + if 'user_id' not in session: + return redirect(url_for('auth.login', next=request.url)) + + # 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 decorated_function + 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() diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..5954eeb --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +"""Application configuration module""" diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..286869c --- /dev/null +++ b/config/settings.py @@ -0,0 +1,39 @@ +"""Application Settings and Configuration""" + +import os +from datetime import timedelta + +# ===== FLASK ===== +DEBUG = os.environ.get('FLASK_DEBUG', False) +SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + +# ===== DATABASE ===== +BASEDIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + f'sqlite:///{os.path.join(BASEDIR, "instance", "app.db")}' +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_ECHO = DEBUG + +# ===== SESSION ===== +PERMANENT_SESSION_LIFETIME = timedelta(days=7) +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' + +# ===== FILE UPLOAD ===== +MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max +UPLOAD_FOLDER = os.path.join(BASEDIR, 'uploads', 'certificates') +ALLOWED_EXTENSIONS = {'pem', 'crt', 'key', 'cert'} + +# ===== HAPROXY ===== +HAPROXY_CONFIG_PATH = '/etc/haproxy/haproxy.cfg' +HAPROXY_BACKUP_DIR = os.path.join(BASEDIR, 'backups') +HAPROXY_STATS_PORT = 8404 # Hardcoded na stałe dla statystyk +HAPROXY_LOG_FILE = '/var/log/haproxy.log' + +# ===== DEFAULT AUTH ===== +DEFAULT_ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin') +DEFAULT_ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123') + +# ===== LOGGING ===== +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..2036f64 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,27 @@ +"""Database initialization""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() + +def init_db(app): + """Initialize database with app""" + db.init_app(app) + migrate.init_app(app, db) + + # Create tables + with app.app_context(): + db.create_all() + + # Create default admin user if not exists + from database.models import User + admin = User.query.filter_by(username='admin').first() + if not admin: + from config.settings import DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD + admin = User(username=DEFAULT_ADMIN_USERNAME) + admin.set_password(DEFAULT_ADMIN_PASSWORD) + db.session.add(admin) + db.session.commit() + print(f"[DB] Created default admin user: {DEFAULT_ADMIN_USERNAME}", flush=True) diff --git a/database/migration.py b/database/migration.py new file mode 100644 index 0000000..2440e16 --- /dev/null +++ b/database/migration.py @@ -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 diff --git a/database/modelspy b/database/modelspy new file mode 100644 index 0000000..3e05a66 --- /dev/null +++ b/database/modelspy @@ -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'' + + +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'' + + +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'' + + +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'' + + +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'' diff --git a/docker-compose.yml b/docker-compose.yml index b54bd20..bdf3117 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: '3.9' + services: haproxy-configurator: build: @@ -6,35 +8,108 @@ services: container_name: haproxy-configurator restart: unless-stopped + # ===== PORTS ===== ports: - - "15000:5000" - - "80:80" - - "443:443" - - "8404:8404" + - "15001:5000" # Flask app (manager UI) + - "81:80" # HAProxy HTTP + - "444:443" # HAProxy HTTPS + - "8405:8404" # HAProxy Stats (hardcoded 8404 inside) + # ===== VOLUMES ===== volumes: - - ./config:/app/config - - ./haproxy:/etc/haproxy - - ./logs:/var/log - - ./ssl:/app/ssl - + # Application data + - ./instance:/app/instance # SQLite database + - ./uploads/certificates:/app/uploads/certificates # SSL certificates + - ./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: + # Flask - FLASK_ENV=production - FLASK_APP=app.py + - FLASK_DEBUG=0 - 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: - NET_ADMIN - SYS_ADMIN + - DAC_OVERRIDE + # ===== LOGGING ===== logging: driver: "json-file" options: max-size: "10m" 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: intranet: - external: true \ No newline at end of file + driver: bridge + # uncomment dla external network: + # external: true diff --git a/entrypoint.sh b/entrypoint.sh index c9834e9..cffb2c8 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,77 +1,41 @@ #!/bin/bash set -e -echo "[$(date)] Starting HAProxy Configurator..." +echo "╔════════════════════════════════════════╗" +echo "║ HAProxy Manager - Entrypoint ║" +echo "║ Starting services... ║" +echo "╚════════════════════════════════════════╝" -# Create directories if they don't exist -mkdir -p /app/config/auth -mkdir -p /app/config/ssl -mkdir -p /etc/haproxy -mkdir -p /var/log/supervisor +# ===== CHECK ENVIRONMENT ===== +echo "[STARTUP] Environment: ${FLASK_ENV:-production}" +echo "[STARTUP] Python version: $(python --version)" -# Create default auth.cfg if doesn't exist -if [ ! -f /app/config/auth/auth.cfg ]; then - cat > /app/config/auth/auth.cfg < /app/config/ssl.ini < /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 +# ===== START SUPERVISOR (runs Flask + HAProxy + other services) ===== +echo "[STARTUP] Starting supervisord..." exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/models.py b/models.py new file mode 100644 index 0000000..aaf2282 --- /dev/null +++ b/models.py @@ -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'' + + +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'' + + +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'' + + +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'' + + +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'' + + +# ===== CONSTANTS (dla config_generator.py) ===== +# HAPROXY_STATS_PORT importujemy z config.settings +# Default: 8404 (hardcoded w config/settings.py) diff --git a/requirements.txt b/requirements.txt index 930f86e..b2e5a6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,12 @@ -flask -requests -pyOpenSSL -Werkzeug \ No newline at end of file +Flask==2.3.3 +Flask-SQLAlchemy==3.0.5 +Flask-Migrate==4.0.5 +Werkzeug==2.3.7 +python-dateutil==2.8.2 +cryptography==41.0.4 +pyopenssl==23.3.0 +requests==2.31.0 +Jinja2==3.1.2 +Markupsafe==2.1.1 +cryptography==41.0.4 +pyOpenSSL==23.3.0 \ No newline at end of file diff --git a/routes/auth_routes.py b/routes/auth_routes.py new file mode 100644 index 0000000..59ff8c7 --- /dev/null +++ b/routes/auth_routes.py @@ -0,0 +1,102 @@ +"""Authentication routes - Login, Logout, Register""" + +from flask import Blueprint, render_template, request, redirect, url_for, session, jsonify, flash +from functools import wraps +from database import db +from database.models import User +from datetime import datetime +import logging + +auth_bp = Blueprint('auth', __name__) +logger = logging.getLogger(__name__) + + +def login_required(f): + """Decorator to require login""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return redirect(url_for('auth.login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + """Decorator to 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: + flash('Admin access required', 'danger') + return redirect(url_for('main.index')) + + return f(*args, **kwargs) + return decorated_function + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """Login page""" + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + if not username or not password: + flash('Username and password required', 'warning') + return redirect(url_for('auth.login')) + + user = User.query.filter_by(username=username).first() + + if user and user.check_password(password): + session['user_id'] = user.id + session['username'] = user.username + session['is_admin'] = user.is_admin + session.permanent = True + + user.last_login = datetime.utcnow() + db.session.commit() + + logger.info(f"[AUTH] User '{username}' logged in", flush=True) + + next_page = request.args.get('next') + if next_page and next_page.startswith('/'): + return redirect(next_page) + return redirect(url_for('main.index')) + + logger.warning(f"[AUTH] Failed login attempt for '{username}'", flush=True) + flash('Invalid username or password', 'danger') + + return render_template('login.html') + + +@auth_bp.route('/logout') +def logout(): + """Logout""" + username = session.get('username', 'Unknown') + session.clear() + logger.info(f"[AUTH] User '{username}' logged out", flush=True) + flash('You have been logged out', 'info') + return redirect(url_for('auth.login')) + + +@auth_bp.route('/api/current-user') +def api_current_user(): + """Get current user info (API)""" + if 'user_id' not in session: + return jsonify({'error': 'Not authenticated'}), 401 + + user = User.query.get(session['user_id']) + if not user: + session.clear() + return jsonify({'error': 'User not found'}), 404 + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'is_admin': user.is_admin, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_login': user.last_login.isoformat() if user.last_login else None + }) diff --git a/routes/cert_routes.py b/routes/cert_routes.py new file mode 100644 index 0000000..58b61fc --- /dev/null +++ b/routes/cert_routes.py @@ -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('/', 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('/', 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('//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 diff --git a/routes/user_routes.py b/routes/user_routes.py new file mode 100644 index 0000000..7e924ab --- /dev/null +++ b/routes/user_routes.py @@ -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('/', 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('/', 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('//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 diff --git a/routes/vhost_routes.py b/routes/vhost_routes.py new file mode 100644 index 0000000..f5e45dd --- /dev/null +++ b/routes/vhost_routes.py @@ -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('/', 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('/', 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('/', 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('//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('//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('//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/', 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/', 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 diff --git a/static/js/cert_manager.js b/static/js/cert_manager.js new file mode 100644 index 0000000..b4dc7b6 --- /dev/null +++ b/static/js/cert_manager.js @@ -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 = 'No certificates uploaded yet'; + 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 ` + + ${escapeHtml(c.name)} + ${escapeHtml(c.common_name || 'N/A')} + + ${isExpired ? 'EXPIRED' : ''} + ${expiresText} + + + ${c.vhost_count > 0 + ? `${c.vhost_count}` + : 'Not used'} + + + ${isExpired + ? 'Expired' + : 'Valid'} + + + + ${c.vhost_count === 0 ? ` + + ` : ''} + + + `; + }).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 => `
  • ${v.name} (${v.hostname})
  • `).join('') + : '
  • Not used
  • '; + + const detailsHTML = ` + + + + + + + + + + + + + + + + + + + + + +
    Common Name${escapeHtml(c.common_name || 'N/A')}
    Subject Alt Names${escapeHtml(san)}
    Issued${c.issued_at ? formatDate(new Date(c.issued_at)) : 'Unknown'}
    Expires + ${c.expires_at ? formatDate(new Date(c.expires_at)) : 'Unknown'} + ${new Date(c.expires_at) < new Date() ? 'EXPIRED' : ''} +
    Created${formatDate(new Date(c.created_at))}
    + +
    Used by VHosts:
    +
      ${vhosts}
    + `; + + 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} + + `; + 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 = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; + return text.replace(/[&<>"']/g, m => map[m]); +} diff --git a/static/js/user_manager.js b/static/js/user_manager.js new file mode 100644 index 0000000..8f57a57 --- /dev/null +++ b/static/js/user_manager.js @@ -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 = 'No users found'; + return; + } + + tbody.innerHTML = users.map(user => ` + + ${escapeHtml(user.username)} + + ${user.is_admin ? 'Admin' : 'User'} + + ${formatDate(user.created_at)} + ${user.last_login ? formatDate(user.last_login) : 'Never'} + + + ${user.id !== getUserId() ? ` + + ` : ''} + + + `).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} + + `; + 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 = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; + 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; +} diff --git a/static/js/vhost_manager.js b/static/js/vhost_manager.js new file mode 100644 index 0000000..a6c39f9 --- /dev/null +++ b/static/js/vhost_manager.js @@ -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 = 'No VHosts created yet'; + return; + } + + tbody.innerHTML = vhosts.map(v => ` + + + ${escapeHtml(v.name)} + ${v.use_ssl ? 'SSL' : ''} + + ${escapeHtml(v.hostname)} + + ${v.frontend_ip}:${v.frontend_port} + + ${v.protocol.toUpperCase()} + + ${v.backend_count} servers + + + + + + + + + + `).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 = ` +
    +
    +
    + +
    +
    + +
    +
    + +
    + `; + 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 = ` +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    Backend Servers
    +
    + ${v.backend_servers.map(bs => ` +
    +
    +
    + +
    +
    + +
    +
    + +
    + `).join('')} +
    + `; + + 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 = ` +
    +
    + +
    +
    + +
    +
    + + `; + container.appendChild(newServer); +} + +function showAlert(message, type) { + const alertDiv = document.createElement('div'); + alertDiv.className = `alert alert-${type} alert-dismissible fade show`; + alertDiv.innerHTML = ` + ${message} + + `; + document.querySelector('.card:first-of-type').parentElement.insertBefore(alertDiv, document.querySelector('.card:first-of-type')); + setTimeout(() => alertDiv.remove(), 5000); +} + +function escapeHtml(text) { + const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; + return text.replace(/[&<>"']/g, m => map[m]); +} diff --git a/supervisord.conf b/supervisord.conf index 64f265b..2831b13 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -1,13 +1,11 @@ [supervisord] nodaemon=true -user=root -loglevel=info logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid +childlogdir=/var/log/supervisor [unix_http_server] file=/var/run/supervisor.sock -chmod=0700 [supervisorctl] serverurl=unix:///var/run/supervisor.sock @@ -15,26 +13,35 @@ serverurl=unix:///var/run/supervisor.sock [rpcinterface:supervisor] supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface -[program:haproxy] -command=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -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] +# ===== FLASK APPLICATION ===== +[program:flask] command=python /app/app.py directory=/app autostart=true autorestart=true -stderr_logfile=/var/log/supervisor/flask_app.err.log -stdout_logfile=/var/log/supervisor/flask_app.out.log -priority=999 -environment=FLASK_APP=/app/app.py,FLASK_ENV=production,PYTHONUNBUFFERED=1 -startsecs=10 +stdout_logfile=/var/log/supervisor/flask.log +stderr_logfile=/var/log/supervisor/flask_error.log 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 diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..b545c67 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Access Denied{% endblock %} + +{% block content %} +
    +
    +

    403

    +

    Access Denied

    +

    You don't have permission to access this resource.

    + + Go Home + +
    +
    +{% endblock %} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..98ed92a --- /dev/null +++ b/templates/404.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block content %} +
    +
    +

    404

    +

    Page Not Found

    +

    The page you're looking for doesn't exist.

    + + Go Home + +
    +
    +{% endblock %} diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..ba9499e --- /dev/null +++ b/templates/500.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Server Error{% endblock %} + +{% block content %} +
    +
    +

    500

    +

    Internal Server Error

    +

    Something went wrong on our end.

    + + Go Home + +
    +
    +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 29ae675..89d0b4a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% set active_page = active_page|default('') %} - + @@ -15,46 +15,180 @@ + + -
    - -
    + + + + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + + {% block breadcrumb %}{% endblock %} + + +
    + + + {% block content %}{% endblock %} +
    + + +
    +
    +
    +
    +

    + © 2025 HAProxy Configurator & Manager +

    +

    + + Powerful web-based HAProxy configuration management system +

    +
    +
    +

    + + Built with Flask + SQLAlchemy + Bootstrap 5 +

    +

    + Based on: Original Project + | Maintained by @linuxiarz.pl +

    +
    +
    +
    +
    + + + + + {% block scripts %}{% endblock %} + {% block page_js %}{% endblock %} + + + diff --git a/templates/certificate_manager.html b/templates/certificate_manager.html new file mode 100644 index 0000000..7efadb8 --- /dev/null +++ b/templates/certificate_manager.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} + +{% block title %}HAProxy • Certificate Manager{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + +
    +

    SSL Certificates

    + +
    + + +
    +
    +
    Certificates List
    +
    +
    +
    + + + + + + + + + + + + + + +
    NameCommon Name (CN)ExpiresUsed ByStatusActions
    Loading...
    +
    +
    +
    + + + + + + + + + +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..21c9b71 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} + +{% block title %}HAProxy • Dashboard{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + +
    +

    Virtual Hosts

    + +
    + + +
    +
    +
    +
    +
    Total VHosts
    +

    0

    +
    +
    +
    +
    +
    +
    +
    Enabled
    +

    0

    +
    +
    +
    +
    +
    +
    +
    Disabled
    +

    0

    +
    +
    +
    +
    +
    +
    +
    SSL Enabled
    +

    0

    +
    +
    +
    +
    + + +
    +
    +
    VHosts List
    +
    +
    +
    + + + + + + + + + + + + + + + +
    NameHostnameBindProtocolServersStatusActions
    Loading...
    +
    +
    +
    + + + + + + + + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1d2c60b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,132 @@ + + + + + + HAProxy Configurator - Login + + + + + + + + + + diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..0d3a081 --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} + +{% set active_page = "users" %} + +{% block title %}HAProxy • User Management{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + +
    +
    +
    User Management
    +
    + +
    + + + + +
    + + + + + + + + + + + + + +
    UsernameRoleCreatedLast LoginActions
    Loading...
    +
    +
    +
    + + + + + + + + + +{% endblock %} diff --git a/utils/cert_manager.py b/utils/cert_manager.py new file mode 100644 index 0000000..a244663 --- /dev/null +++ b/utils/cert_manager.py @@ -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 diff --git a/utils/config_generator.py b/utils/config_generator.py new file mode 100644 index 0000000..8ae2bcb --- /dev/null +++ b/utils/config_generator.py @@ -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, HAPROXY_STATS_PORT +from config.settings import HAPROXY_CONFIG_PATH, HAPROXY_BACKUP_DIR +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