Compare commits
19 Commits
32ef62e4ac
...
allin_sqli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc28155fe | ||
|
|
dd31c1bdd0 | ||
|
|
762e51f886 | ||
|
|
84759f9508 | ||
|
|
5a687549a9 | ||
|
|
577dc789fc | ||
|
|
398ccce3b5 | ||
|
|
411291c8b9 | ||
|
|
bb3aa9f179 | ||
|
|
f8a05554c1 | ||
|
|
f5cfc5bb33 | ||
|
|
06fce272c1 | ||
|
|
75e3718e70 | ||
|
|
899e698353 | ||
|
|
03b7f20b8c | ||
|
|
1c6ecb9230 | ||
|
|
b85efadd87 | ||
|
|
0c321859b9 | ||
|
|
addb21bc3e |
@@ -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/
|
||||
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# ===== FLASK =====
|
||||
FLASK_ENV=production
|
||||
FLASK_APP=app.py
|
||||
FLASK_DEBUG=0
|
||||
|
||||
# ===== DATABASE =====
|
||||
DATABASE_URL=sqlite:////app/instance/app.db
|
||||
|
||||
# ===== SECURITY =====
|
||||
SECRET_KEY=your-super-secret-key-change-this
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# ===== HAPROXY =====
|
||||
HAPROXY_CONFIG_PATH=/etc/haproxy/haproxy.cfg
|
||||
HAPROXY_STATS_PORT=8404
|
||||
|
||||
# ===== LOGGING =====
|
||||
LOG_LEVEL=INFO
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
venv
|
||||
logs/*
|
||||
__pycache__
|
||||
config/*
|
||||
config/auth/*
|
||||
71
Dockerfile
71
Dockerfile
@@ -1,49 +1,80 @@
|
||||
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 \
|
||||
supervisor \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
supervisor \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
wget \
|
||||
git \
|
||||
vim \
|
||||
libssl-dev \
|
||||
libffi-dev \
|
||||
python3-dev \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# ===== WORKDIR =====
|
||||
WORKDIR /app
|
||||
|
||||
# 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 (POPRAWIONE - && ВЕЗДЕ!) =====
|
||||
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"]
|
||||
|
||||
22
README.md
22
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!)
|
||||
|
||||
368
app.py
368
app.py
@@ -1,196 +1,252 @@
|
||||
"""
|
||||
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 routes.vhost_routes import vhost_bp
|
||||
from routes.cert_routes import cert_bp
|
||||
from auth.auth_middleware import setup_auth, login_required
|
||||
from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats
|
||||
from auth.auth_middleware import setup_auth
|
||||
from log_parser import parse_log_file
|
||||
from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends
|
||||
|
||||
|
||||
# ===== BASE DIRECTORY =====
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
# ===== 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'] = False
|
||||
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)
|
||||
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')
|
||||
if os.path.exists(SSL_INI):
|
||||
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)
|
||||
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)
|
||||
print(f"[APP] No SSL config file found: {SSL_INI}", 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
|
||||
}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
"""Run before each request"""
|
||||
pass
|
||||
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
"""Run after each request"""
|
||||
return response
|
||||
|
||||
|
||||
# ===== CLI COMMANDS =====
|
||||
|
||||
@app.cli.command()
|
||||
def init_db_cli():
|
||||
"""Initialize database"""
|
||||
init_db(app)
|
||||
print("[CLI] Database initialized successfully", flush=True)
|
||||
|
||||
|
||||
@app.cli.command()
|
||||
def create_admin():
|
||||
"""Create admin user"""
|
||||
import getpass
|
||||
username = input("Username: ")
|
||||
password = getpass.getpass("Password: ")
|
||||
|
||||
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)
|
||||
# ===== 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)
|
||||
|
||||
# Initialize database before running
|
||||
init_db(app)
|
||||
|
||||
app.run(
|
||||
host='::',
|
||||
port=5000,
|
||||
ssl_context=None,
|
||||
debug=DEBUG,
|
||||
use_reloader=DEBUG
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application configuration module"""
|
||||
48
config/settings.py
Normal file
48
config/settings.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Application Settings"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
# ===== ENVIRONMENT =====
|
||||
DEBUG = os.getenv('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
ENV = os.getenv('FLASK_ENV', 'production')
|
||||
|
||||
# ===== BASE PATHS =====
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads/certificates')
|
||||
HAPROXY_BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
|
||||
|
||||
# ===== DATABASE =====
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
f'sqlite:///{os.path.join(INSTANCE_DIR, "app.db")}'
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# ===== FLASK SETTINGS =====
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# ===== HAPROXY =====
|
||||
HAPROXY_CONFIG_PATH = os.getenv('HAPROXY_CONFIG_PATH', '/etc/haproxy/haproxy.cfg')
|
||||
HAPROXY_BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
|
||||
HAPROXY_STATS_PORT = int(os.getenv('HAPROXY_STATS_PORT', '8404'))
|
||||
|
||||
# ===== SSL =====
|
||||
SSL_INI = os.path.join(BASE_DIR, 'config', 'ssl.ini')
|
||||
|
||||
# ===== MAX UPLOAD SIZE =====
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
||||
|
||||
# ===== LOGGING =====
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
LOG_FILE = os.path.join(BASE_DIR, 'logs', 'app.log')
|
||||
|
||||
os.makedirs(INSTANCE_DIR, exist_ok=True)
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(HAPROXY_BACKUP_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||
45
database/__init__.py
Normal file
45
database/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Database module initialization"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
import logging
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""Initialize database - create tables"""
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
db.create_all()
|
||||
print("[DB] All tables created successfully", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[DB] Error creating tables: {e}", flush=True)
|
||||
raise
|
||||
|
||||
from database.models import User
|
||||
|
||||
try:
|
||||
admin = User.query.filter_by(username='admin').first()
|
||||
|
||||
if not admin:
|
||||
print("[DB] Creating default admin user...", flush=True)
|
||||
admin = User(username='admin', is_admin=True)
|
||||
admin.set_password('admin123')
|
||||
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print("[DB] Default admin user created (admin/admin123)", flush=True)
|
||||
print(f"[DB] Hash: {admin.password_hash}", flush=True)
|
||||
else:
|
||||
print("[DB] Admin user already exists", flush=True)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DB] Error creating admin: {e}", flush=True)
|
||||
db.session.rollback()
|
||||
raise
|
||||
83
database/migration.py
Normal file
83
database/migration.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Migration: Import existing HAProxy config to database"""
|
||||
|
||||
import re
|
||||
from database import db
|
||||
from database.models import VirtualHost, BackendServer
|
||||
|
||||
def parse_existing_haproxy_config(config_path):
|
||||
"""Parse existing haproxy.cfg and import to DB"""
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config_content = f.read()
|
||||
|
||||
print(f"[MIGRATION] Parsing {config_path}...")
|
||||
|
||||
# Pattern: frontend FNAME ...
|
||||
frontend_pattern = r'frontend\s+(\S+)\s*\n(.*?)(?=\n\nbackend|\Z)'
|
||||
frontends = re.findall(frontend_pattern, config_content, re.DOTALL)
|
||||
|
||||
vhost_count = 0
|
||||
for fe_name, fe_content in frontends:
|
||||
# Skip stats frontend
|
||||
if 'stats' in fe_name.lower():
|
||||
continue
|
||||
|
||||
# Extract bind
|
||||
bind_match = re.search(r'bind\s+([^\s:]+):(\d+)', fe_content)
|
||||
if not bind_match:
|
||||
continue
|
||||
|
||||
ip, port = bind_match.groups()
|
||||
|
||||
# Extract backend name
|
||||
backend_match = re.search(r'default_backend\s+(\S+)', fe_content)
|
||||
backend_name = backend_match.group(1) if backend_match else f'be_{fe_name}'
|
||||
|
||||
# Check if already exists
|
||||
vhost = VirtualHost.query.filter_by(name=fe_name).first()
|
||||
if vhost:
|
||||
print(f"[MIGRATION] Vhost '{fe_name}' already exists, skipping...")
|
||||
continue
|
||||
|
||||
# Create vhost
|
||||
vhost = VirtualHost(
|
||||
name=fe_name,
|
||||
hostname=f"{fe_name}.local",
|
||||
frontend_ip=ip,
|
||||
frontend_port=int(port),
|
||||
protocol='http',
|
||||
use_ssl='ssl' in fe_content,
|
||||
enabled=True
|
||||
)
|
||||
|
||||
db.session.add(vhost)
|
||||
db.session.flush() # Get vhost.id
|
||||
|
||||
# Parse backend servers
|
||||
be_pattern = rf'backend\s+{re.escape(backend_name)}\s*\n(.*?)(?=\nbackend|\Z)'
|
||||
be_match = re.search(be_pattern, config_content, re.DOTALL)
|
||||
|
||||
if be_match:
|
||||
servers_pattern = r'server\s+(\S+)\s+([^\s:]+):(\d+)'
|
||||
servers = re.findall(servers_pattern, be_match.group(1))
|
||||
|
||||
for srv_name, srv_ip, srv_port in servers:
|
||||
server = BackendServer(
|
||||
vhost_id=vhost.id,
|
||||
name=srv_name,
|
||||
ip_address=srv_ip,
|
||||
port=int(srv_port),
|
||||
enabled=True
|
||||
)
|
||||
db.session.add(server)
|
||||
|
||||
vhost_count += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f"[MIGRATION] Successfully imported {vhost_count} vhosts!", flush=True)
|
||||
return vhost_count
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"[MIGRATION] Error: {e}", flush=True)
|
||||
return 0
|
||||
170
database/models.py
Normal file
170
database/models.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Database Models"""
|
||||
|
||||
from datetime import datetime
|
||||
from database import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import json
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model for authentication"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
def set_password(self, password):
|
||||
"""Hash and set password"""
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
|
||||
def check_password(self, password):
|
||||
"""Verify password"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
|
||||
class Certificate(db.Model):
|
||||
"""SSL Certificate storage"""
|
||||
__tablename__ = 'certificates'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
|
||||
cert_content = db.Column(db.Text, nullable=False) # Full PEM (cert + key combined)
|
||||
cert_only = db.Column(db.Text) # Separate cert (for info)
|
||||
key_only = db.Column(db.Text) # Separate key (for backup)
|
||||
|
||||
# Metadata
|
||||
common_name = db.Column(db.String(255))
|
||||
subject_alt_names = db.Column(db.Text) # JSON array
|
||||
issued_at = db.Column(db.DateTime)
|
||||
expires_at = db.Column(db.DateTime)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
vhosts = db.relationship('VirtualHost', backref='certificate', lazy=True)
|
||||
|
||||
def get_san_list(self):
|
||||
"""Get Subject Alternative Names as list"""
|
||||
if self.subject_alt_names:
|
||||
try:
|
||||
return json.loads(self.subject_alt_names)
|
||||
except:
|
||||
return []
|
||||
return []
|
||||
|
||||
def set_san_list(self, san_list):
|
||||
"""Set Subject Alternative Names from list"""
|
||||
self.subject_alt_names = json.dumps(san_list)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Certificate {self.name}>'
|
||||
|
||||
|
||||
class VirtualHost(db.Model):
|
||||
"""Virtual Host / Proxy configuration"""
|
||||
__tablename__ = 'virtual_hosts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
|
||||
hostname = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# ===== FRONTEND SETTINGS =====
|
||||
frontend_ip = db.Column(db.String(50), default='0.0.0.0')
|
||||
frontend_port = db.Column(db.Integer, default=443)
|
||||
protocol = db.Column(db.String(10), default='http') # http or tcp
|
||||
|
||||
# ===== SSL SETTINGS =====
|
||||
use_ssl = db.Column(db.Boolean, default=False)
|
||||
certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id'))
|
||||
ssl_redirect = db.Column(db.Boolean, default=False)
|
||||
ssl_redirect_port = db.Column(db.Integer, default=80)
|
||||
|
||||
# ===== LOAD BALANCING =====
|
||||
lb_method = db.Column(db.String(50), default='roundrobin') # roundrobin, leastconn, source, uri
|
||||
|
||||
# ===== SECURITY OPTIONS =====
|
||||
dos_protection = db.Column(db.Boolean, default=False)
|
||||
dos_ban_duration = db.Column(db.String(20), default='30m')
|
||||
dos_limit_requests = db.Column(db.Integer, default=100)
|
||||
|
||||
sql_injection_check = db.Column(db.Boolean, default=False)
|
||||
xss_check = db.Column(db.Boolean, default=False)
|
||||
webshell_check = db.Column(db.Boolean, default=False)
|
||||
|
||||
# ===== HEADERS =====
|
||||
add_custom_header = db.Column(db.Boolean, default=False)
|
||||
custom_header_name = db.Column(db.String(200))
|
||||
custom_header_value = db.Column(db.String(500))
|
||||
del_server_header = db.Column(db.Boolean, default=False)
|
||||
forward_for = db.Column(db.Boolean, default=True)
|
||||
|
||||
# ===== STATE =====
|
||||
enabled = db.Column(db.Boolean, default=True, index=True)
|
||||
|
||||
# ===== TIMESTAMPS =====
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
backend_servers = db.relationship('BackendServer', backref='vhost',
|
||||
lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<VirtualHost {self.name}>'
|
||||
|
||||
|
||||
class BackendServer(db.Model):
|
||||
"""Backend server for virtual host"""
|
||||
__tablename__ = 'backend_servers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'), nullable=False)
|
||||
|
||||
# Server info
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
ip_address = db.Column(db.String(50), nullable=False)
|
||||
port = db.Column(db.Integer, nullable=False)
|
||||
maxconn = db.Column(db.Integer)
|
||||
weight = db.Column(db.Integer, default=1)
|
||||
|
||||
# Health check
|
||||
health_check = db.Column(db.Boolean, default=False)
|
||||
health_check_path = db.Column(db.String(200), default='/')
|
||||
|
||||
# State
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<BackendServer {self.name}:{self.port}>'
|
||||
|
||||
|
||||
class ConfigHistory(db.Model):
|
||||
"""History of HAProxy configuration changes"""
|
||||
__tablename__ = 'config_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
config_content = db.Column(db.Text, nullable=False)
|
||||
change_type = db.Column(db.String(50)) # vhost_create, vhost_edit, vhost_delete, manual_edit
|
||||
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
description = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Relationships
|
||||
vhost = db.relationship('VirtualHost')
|
||||
user = db.relationship('User')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ConfigHistory {self.change_type} at {self.created_at}>'
|
||||
@@ -1,40 +1,115 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
haproxy-configurator:
|
||||
haproxy-configurator-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: haproxy-configurator
|
||||
container_name: haproxy-configurator-app
|
||||
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
|
||||
driver: bridge
|
||||
# uncomment dla external network:
|
||||
# external: true
|
||||
|
||||
102
entrypoint.sh
102
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 <<EOF
|
||||
[auth]
|
||||
username = admin
|
||||
password = admin123
|
||||
EOF
|
||||
echo "[$(date)] Created default auth.cfg"
|
||||
fi
|
||||
# ===== INIT DATABASE =====
|
||||
echo "[STARTUP] Initializing database..."
|
||||
python -c "
|
||||
from app import app
|
||||
from database import init_db
|
||||
with app.app_context():
|
||||
init_db(app)
|
||||
print('[STARTUP] Database initialized successfully')
|
||||
" || {
|
||||
echo "[ERROR] Database initialization failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create default ssl.ini if doesn't exist
|
||||
if [ ! -f /app/config/ssl.ini ]; then
|
||||
cat > /app/config/ssl.ini <<EOF
|
||||
[ssl]
|
||||
certificate_path = /app/config/ssl/haproxy-configurator.pem
|
||||
private_key_path = /app/config/ssl/haproxy-configurator.pem
|
||||
EOF
|
||||
echo "[$(date)] Created default ssl.ini"
|
||||
fi
|
||||
# ===== GENERATE INITIAL HAPROXY CONFIG =====
|
||||
echo "[STARTUP] Generating initial HAProxy config..."
|
||||
python -c "
|
||||
from app import app
|
||||
from utils.config_generator import generate_haproxy_config, save_haproxy_config
|
||||
with app.app_context():
|
||||
config = generate_haproxy_config()
|
||||
save_haproxy_config(config)
|
||||
print('[STARTUP] HAProxy config generated')
|
||||
" || {
|
||||
echo "[WARNING] Could not generate initial config, continuing..."
|
||||
}
|
||||
|
||||
# Generate self-signed certificate if doesn't exist
|
||||
if [ ! -f /app/config/ssl/haproxy-configurator.pem ]; then
|
||||
openssl req -x509 -newkey rsa:2048 -keyout /app/config/ssl/haproxy-configurator.pem \
|
||||
-out /app/config/ssl/haproxy-configurator.pem -days 365 -nodes \
|
||||
-subj "/C=PL/ST=State/L=City/O=Organization/CN=haproxy-configurator.local"
|
||||
chmod 600 /app/config/ssl/haproxy-configurator.pem
|
||||
echo "[$(date)] Generated SSL certificate"
|
||||
fi
|
||||
|
||||
# Create default haproxy.cfg if doesn't exist or is empty
|
||||
if [ ! -s /etc/haproxy/haproxy.cfg ]; then
|
||||
cat > /etc/haproxy/haproxy.cfg <<'HAPROXYCFG'
|
||||
global
|
||||
log stdout local0
|
||||
maxconn 4096
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
option dontlognull
|
||||
timeout connect 5000
|
||||
timeout client 50000
|
||||
timeout server 50000
|
||||
|
||||
listen stats
|
||||
bind *:8404
|
||||
stats enable
|
||||
stats uri /stats
|
||||
stats refresh 30s
|
||||
stats show-legends
|
||||
HAPROXYCFG
|
||||
echo "[$(date)] Created default haproxy.cfg"
|
||||
fi
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 /app/config/ssl/haproxy-configurator.pem 2>/dev/null || true
|
||||
chmod 644 /app/config/auth/auth.cfg
|
||||
chmod 644 /app/config/ssl.ini
|
||||
chmod 644 /etc/haproxy/haproxy.cfg
|
||||
|
||||
echo "[$(date)] Configuration ready"
|
||||
echo "[$(date)] Starting supervisord..."
|
||||
|
||||
# Start supervisord
|
||||
# ===== START SUPERVISOR (runs Flask + HAProxy + other services) =====
|
||||
echo "[STARTUP] Starting supervisord..."
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
178
models.py
Normal file
178
models.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Database Models - HAProxy Manager"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
from config.settings import HAPROXY_STATS_PORT
|
||||
import json
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model for authentication"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
def set_password(self, password):
|
||||
"""Hash and set password"""
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
|
||||
def check_password(self, password):
|
||||
"""Verify password"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
|
||||
class Certificate(db.Model):
|
||||
"""SSL Certificate storage"""
|
||||
__tablename__ = 'certificates'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
|
||||
cert_content = db.Column(db.Text, nullable=False) # Full PEM (cert + key combined)
|
||||
cert_only = db.Column(db.Text) # Separate cert (for info)
|
||||
key_only = db.Column(db.Text) # Separate key (for backup)
|
||||
|
||||
# Metadata
|
||||
common_name = db.Column(db.String(255))
|
||||
subject_alt_names = db.Column(db.Text) # JSON array
|
||||
issued_at = db.Column(db.DateTime)
|
||||
expires_at = db.Column(db.DateTime)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
vhosts = db.relationship('VirtualHost', backref='certificate', lazy=True)
|
||||
|
||||
def get_san_list(self):
|
||||
"""Get Subject Alternative Names as list"""
|
||||
if self.subject_alt_names:
|
||||
try:
|
||||
return json.loads(self.subject_alt_names)
|
||||
except:
|
||||
return []
|
||||
return []
|
||||
|
||||
def set_san_list(self, san_list):
|
||||
"""Set Subject Alternative Names from list"""
|
||||
self.subject_alt_names = json.dumps(san_list)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Certificate {self.name}>'
|
||||
|
||||
|
||||
class VirtualHost(db.Model):
|
||||
"""Virtual Host / Proxy configuration"""
|
||||
__tablename__ = 'virtual_hosts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
|
||||
hostname = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# ===== FRONTEND SETTINGS =====
|
||||
frontend_ip = db.Column(db.String(50), default='0.0.0.0')
|
||||
frontend_port = db.Column(db.Integer, default=443)
|
||||
protocol = db.Column(db.String(10), default='http') # http or tcp
|
||||
|
||||
# ===== SSL SETTINGS =====
|
||||
use_ssl = db.Column(db.Boolean, default=False)
|
||||
certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id'))
|
||||
ssl_redirect = db.Column(db.Boolean, default=False)
|
||||
ssl_redirect_port = db.Column(db.Integer, default=80)
|
||||
|
||||
# ===== LOAD BALANCING =====
|
||||
lb_method = db.Column(db.String(50), default='roundrobin') # roundrobin, leastconn, source, uri
|
||||
|
||||
# ===== SECURITY OPTIONS =====
|
||||
dos_protection = db.Column(db.Boolean, default=False)
|
||||
dos_ban_duration = db.Column(db.String(20), default='30m')
|
||||
dos_limit_requests = db.Column(db.Integer, default=100)
|
||||
|
||||
sql_injection_check = db.Column(db.Boolean, default=False)
|
||||
xss_check = db.Column(db.Boolean, default=False)
|
||||
webshell_check = db.Column(db.Boolean, default=False)
|
||||
|
||||
# ===== HEADERS =====
|
||||
add_custom_header = db.Column(db.Boolean, default=False)
|
||||
custom_header_name = db.Column(db.String(200))
|
||||
custom_header_value = db.Column(db.String(500))
|
||||
del_server_header = db.Column(db.Boolean, default=False)
|
||||
forward_for = db.Column(db.Boolean, default=True)
|
||||
|
||||
# ===== STATE =====
|
||||
enabled = db.Column(db.Boolean, default=True, index=True)
|
||||
|
||||
# ===== TIMESTAMPS =====
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
backend_servers = db.relationship('BackendServer', backref='vhost',
|
||||
lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<VirtualHost {self.name}>'
|
||||
|
||||
|
||||
class BackendServer(db.Model):
|
||||
"""Backend server for virtual host"""
|
||||
__tablename__ = 'backend_servers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'), nullable=False)
|
||||
|
||||
# Server info
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
ip_address = db.Column(db.String(50), nullable=False)
|
||||
port = db.Column(db.Integer, nullable=False)
|
||||
maxconn = db.Column(db.Integer)
|
||||
weight = db.Column(db.Integer, default=1)
|
||||
|
||||
# Health check
|
||||
health_check = db.Column(db.Boolean, default=False)
|
||||
health_check_path = db.Column(db.String(200), default='/')
|
||||
|
||||
# State
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<BackendServer {self.name}:{self.port}>'
|
||||
|
||||
|
||||
class ConfigHistory(db.Model):
|
||||
"""History of HAProxy configuration changes"""
|
||||
__tablename__ = 'config_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
config_content = db.Column(db.Text, nullable=False)
|
||||
change_type = db.Column(db.String(50)) # vhost_create, vhost_edit, vhost_delete, manual_edit
|
||||
vhost_id = db.Column(db.Integer, db.ForeignKey('virtual_hosts.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
description = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Relationships
|
||||
vhost = db.relationship('VirtualHost')
|
||||
user = db.relationship('User')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ConfigHistory {self.change_type} at {self.created_at}>'
|
||||
|
||||
|
||||
# ===== CONSTANTS (dla config_generator.py) =====
|
||||
# HAPROXY_STATS_PORT importujemy z config.settings
|
||||
# Default: 8404 (hardcoded w config/settings.py)
|
||||
@@ -1,4 +1,11 @@
|
||||
flask
|
||||
Flask
|
||||
Flask-SQLAlchemy
|
||||
Flask-Migrate
|
||||
Werkzeug
|
||||
python-dateutil
|
||||
cryptography
|
||||
pyopenssl
|
||||
requests
|
||||
pyOpenSSL
|
||||
Werkzeug
|
||||
Jinja2
|
||||
Markupsafe
|
||||
pyOpenSSL
|
||||
122
routes/auth_routes.py
Normal file
122
routes/auth_routes.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Authentication routes - Login, Logout"""
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, jsonify
|
||||
from functools import wraps
|
||||
from database import db
|
||||
from database.models import User
|
||||
import logging
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator - require user to be logged in"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator - require admin role"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = User.query.get(session['user_id'])
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({'error': 'Admin access required', 'success': False}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Login page and authentication"""
|
||||
if request.method == 'GET':
|
||||
# Check if already logged in
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
# POST - process login
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if not username or not password:
|
||||
return render_template('login.html', error='Username and password required'), 400
|
||||
|
||||
try:
|
||||
# Find user
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user:
|
||||
logger.warning(f"[AUTH] Login failed - user '{username}' not found", flush=True)
|
||||
return render_template('login.html', error='Invalid credentials'), 401
|
||||
|
||||
# Check password
|
||||
if not user.check_password(password):
|
||||
logger.warning(f"[AUTH] Login failed - wrong password for '{username}'", flush=True)
|
||||
return render_template('login.html', error='Invalid credentials'), 401
|
||||
|
||||
session.clear()
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['is_admin'] = user.is_admin
|
||||
session.permanent = True
|
||||
|
||||
# Zaloguj w basie danych
|
||||
from datetime import datetime
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[AUTH] User '{username}' logged in successfully", flush=True)
|
||||
|
||||
# Redirect do dashboard
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTH] Login error: {e}", flush=True)
|
||||
return render_template('login.html', error='Login error'), 500
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['GET', 'POST'])
|
||||
def logout():
|
||||
"""Logout"""
|
||||
username = session.get('username', 'unknown')
|
||||
session.clear()
|
||||
logger.info(f"[AUTH] User '{username}' logged out", flush=True)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/api/current-user', methods=['GET'])
|
||||
def current_user():
|
||||
"""Get current logged in user info"""
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'error': 'Not authenticated', 'success': False}), 401
|
||||
|
||||
try:
|
||||
user = User.query.get(session['user_id'])
|
||||
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({'error': 'User not found', 'success': False}), 401
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'is_admin': user.is_admin
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTH] Error getting current user: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
217
routes/cert_routes.py
Normal file
217
routes/cert_routes.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Certificate Management - Upload, List, Delete"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from functools import wraps
|
||||
from database import db
|
||||
from database.models import Certificate, VirtualHost
|
||||
from utils.cert_manager import parse_certificate, save_cert_file, delete_cert_file
|
||||
from config.settings import UPLOAD_FOLDER, MAX_CONTENT_LENGTH
|
||||
from datetime import datetime
|
||||
import os
|
||||
import logging
|
||||
|
||||
cert_bp = Blueprint('certs', __name__, url_prefix='/api/certificates')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_required_api(f):
|
||||
"""API version of login required"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'error': 'Not authenticated', 'success': False}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@cert_bp.route('', methods=['GET'])
|
||||
@login_required_api
|
||||
def list_certificates():
|
||||
"""Get all certificates"""
|
||||
try:
|
||||
certs = Certificate.query.order_by(Certificate.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'certificates': [{
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'common_name': c.common_name,
|
||||
'expires_at': c.expires_at.isoformat() if c.expires_at else None,
|
||||
'created_at': c.created_at.isoformat(),
|
||||
'vhost_count': len(c.vhosts),
|
||||
'is_expired': c.expires_at < datetime.utcnow() if c.expires_at else False
|
||||
} for c in certs]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[CERTS] Error listing: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@cert_bp.route('/<int:cert_id>', methods=['GET'])
|
||||
@login_required_api
|
||||
def get_certificate(cert_id):
|
||||
"""Get certificate details"""
|
||||
try:
|
||||
cert = Certificate.query.get(cert_id)
|
||||
if not cert:
|
||||
return jsonify({'error': 'Certificate not found', 'success': False}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'certificate': {
|
||||
'id': cert.id,
|
||||
'name': cert.name,
|
||||
'common_name': cert.common_name,
|
||||
'subject_alt_names': cert.get_san_list(),
|
||||
'issued_at': cert.issued_at.isoformat() if cert.issued_at else None,
|
||||
'expires_at': cert.expires_at.isoformat() if cert.expires_at else None,
|
||||
'created_at': cert.created_at.isoformat(),
|
||||
'vhosts': [{
|
||||
'id': v.id,
|
||||
'name': v.name,
|
||||
'hostname': v.hostname
|
||||
} for v in cert.vhosts]
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[CERTS] Error getting cert {cert_id}: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@cert_bp.route('', methods=['POST'])
|
||||
@login_required_api
|
||||
def upload_certificate():
|
||||
"""Upload SSL certificate (PEM format)"""
|
||||
try:
|
||||
# Check if file provided
|
||||
if 'cert_file' not in request.files:
|
||||
return jsonify({'error': 'No certificate file provided', 'success': False}), 400
|
||||
|
||||
file = request.files['cert_file']
|
||||
cert_name = request.form.get('name', '').strip()
|
||||
|
||||
if not file or file.filename == '':
|
||||
return jsonify({'error': 'No selected file', 'success': False}), 400
|
||||
|
||||
if not cert_name:
|
||||
return jsonify({'error': 'Certificate name required', 'success': False}), 400
|
||||
|
||||
# Check if name already exists
|
||||
if Certificate.query.filter_by(name=cert_name).first():
|
||||
return jsonify({'error': 'Certificate name already exists', 'success': False}), 400
|
||||
|
||||
# Read file content
|
||||
cert_content = file.read().decode('utf-8')
|
||||
|
||||
# Validate and parse certificate
|
||||
cert_data = parse_certificate(cert_content)
|
||||
if 'error' in cert_data:
|
||||
return jsonify({'error': cert_data['error'], 'success': False}), 400
|
||||
|
||||
# Create certificate record
|
||||
cert = Certificate(
|
||||
name=cert_name,
|
||||
cert_content=cert_content,
|
||||
cert_only=cert_data.get('cert_only'),
|
||||
key_only=cert_data.get('key_only'),
|
||||
common_name=cert_data.get('common_name'),
|
||||
issued_at=cert_data.get('issued_at'),
|
||||
expires_at=cert_data.get('expires_at')
|
||||
)
|
||||
|
||||
if cert_data.get('subject_alt_names'):
|
||||
cert.set_san_list(cert_data['subject_alt_names'])
|
||||
|
||||
db.session.add(cert)
|
||||
db.session.flush()
|
||||
|
||||
# Save cert file to disk
|
||||
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert_name}.pem')
|
||||
if not save_cert_file(cert_path, cert_content):
|
||||
db.session.rollback()
|
||||
return jsonify({'error': 'Failed to save certificate file', 'success': False}), 500
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[CERTS] Uploaded certificate '{cert_name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': cert.id,
|
||||
'name': cert.name,
|
||||
'message': 'Certificate uploaded successfully'
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[CERTS] Error uploading cert: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@cert_bp.route('/<int:cert_id>', methods=['DELETE'])
|
||||
@login_required_api
|
||||
def delete_certificate(cert_id):
|
||||
"""Delete certificate"""
|
||||
try:
|
||||
cert = Certificate.query.get(cert_id)
|
||||
if not cert:
|
||||
return jsonify({'error': 'Certificate not found', 'success': False}), 404
|
||||
|
||||
# Check if certificate is in use
|
||||
if cert.vhosts:
|
||||
vhost_names = [v.name for v in cert.vhosts]
|
||||
return jsonify({
|
||||
'error': f'Certificate is in use by vhosts: {", ".join(vhost_names)}',
|
||||
'success': False
|
||||
}), 400
|
||||
|
||||
cert_name = cert.name
|
||||
|
||||
# Delete file from disk
|
||||
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert_name}.pem')
|
||||
delete_cert_file(cert_path)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(cert)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[CERTS] Deleted certificate '{cert_name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Certificate {cert_name} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[CERTS] Error deleting cert: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@cert_bp.route('/<int:cert_id>/export', methods=['GET'])
|
||||
@login_required_api
|
||||
def export_certificate(cert_id):
|
||||
"""Download certificate in PEM format"""
|
||||
try:
|
||||
cert = Certificate.query.get(cert_id)
|
||||
if not cert:
|
||||
return jsonify({'error': 'Certificate not found', 'success': False}), 404
|
||||
|
||||
# Read from disk
|
||||
cert_path = os.path.join(UPLOAD_FOLDER, f'{cert.name}.pem')
|
||||
if not os.path.exists(cert_path):
|
||||
return jsonify({'error': 'Certificate file not found', 'success': False}), 404
|
||||
|
||||
with open(cert_path, 'r') as f:
|
||||
cert_content = f.read()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'name': cert.name,
|
||||
'content': cert_content
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CERTS] Error exporting cert: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
@@ -1,126 +1,21 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
import subprocess
|
||||
from auth.auth_middleware import requires_auth
|
||||
"""Edit HAProxy configuration"""
|
||||
|
||||
edit_bp = Blueprint('edit', __name__)
|
||||
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for
|
||||
from routes.auth_routes import login_required
|
||||
from database.models import VirtualHost
|
||||
import logging
|
||||
|
||||
@edit_bp.route('/edit', methods=['GET', 'POST'])
|
||||
@requires_auth
|
||||
edit_bp = Blueprint('edit', __name__, url_prefix='/edit')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@edit_bp.route('/')
|
||||
@login_required
|
||||
def edit_haproxy_config():
|
||||
if request.method == 'POST':
|
||||
edited_config = request.form.get('haproxy_config', '')
|
||||
action = request.form.get('action', 'check')
|
||||
|
||||
print(f"[EDIT] POST action: {action}", flush=True)
|
||||
|
||||
try:
|
||||
with open('/etc/haproxy/haproxy.cfg', 'w') as f:
|
||||
f.write(edited_config)
|
||||
print(f"[EDIT] Configuration saved successfully", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[EDIT] Error writing config: {e}", flush=True)
|
||||
return render_template(
|
||||
'edit.html',
|
||||
config_content=edited_config,
|
||||
check_output=f"Error writing configuration: {e}",
|
||||
check_level="danger"
|
||||
)
|
||||
|
||||
check_output = ""
|
||||
check_level = "success"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
check_output = (result.stdout or '').strip()
|
||||
|
||||
if result.returncode == 0:
|
||||
if not check_output:
|
||||
check_output = "Configuration file is valid"
|
||||
check_level = "success"
|
||||
|
||||
if "Warning" in check_output or "Warnings" in check_output:
|
||||
check_level = "warning"
|
||||
check_output = f"⚠ {check_output}"
|
||||
else:
|
||||
check_output = f"✓ {check_output}"
|
||||
|
||||
print(f"[EDIT] Config validation: SUCCESS", flush=True)
|
||||
else:
|
||||
if not check_output:
|
||||
check_output = f"Check failed with return code {result.returncode}"
|
||||
check_output = f"✗ {check_output}"
|
||||
check_level = "danger"
|
||||
print(f"[EDIT] Config validation: FAILED - {check_output}", flush=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
check_output = "✗ Configuration check timed out"
|
||||
check_level = "danger"
|
||||
print(f"[EDIT] Config validation: TIMEOUT", flush=True)
|
||||
except Exception as e:
|
||||
check_output = f"✗ Error checking config: {e}"
|
||||
check_level = "danger"
|
||||
print(f"[EDIT] Config validation ERROR: {e}", flush=True)
|
||||
|
||||
if action == "save" and check_level == "success":
|
||||
print(f"[EDIT] Attempting HAProxy restart...", flush=True)
|
||||
try:
|
||||
restart_result = subprocess.run(
|
||||
['pkill', '-f', 'haproxy'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if restart_result.returncode == 0 or 'No such process' in restart_result.stdout:
|
||||
check_output += "\n\n✓ HAProxy restart signal sent successfully"
|
||||
check_output += "\n(supervisord will restart the process)"
|
||||
print(f"[EDIT] HAProxy restart successful", flush=True)
|
||||
else:
|
||||
check_output += f"\n\n⚠ Restart returned code {restart_result.returncode}"
|
||||
if restart_result.stdout:
|
||||
check_output += f"\nOutput: {restart_result.stdout}"
|
||||
check_level = "warning"
|
||||
print(f"[EDIT] Restart warning: {restart_result.stdout}", flush=True)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
check_output += "\n\n⚠ Restart command timed out"
|
||||
check_level = "warning"
|
||||
print(f"[EDIT] Restart TIMEOUT", flush=True)
|
||||
except Exception as e:
|
||||
check_output += f"\n\n⚠ Restart error: {e}"
|
||||
check_level = "warning"
|
||||
print(f"[EDIT] Restart ERROR: {e}", flush=True)
|
||||
|
||||
print(f"[EDIT] Returning check_level={check_level}, output length={len(check_output)}", flush=True)
|
||||
|
||||
return render_template(
|
||||
'edit.html',
|
||||
config_content=edited_config,
|
||||
check_output=check_output,
|
||||
check_level=check_level
|
||||
)
|
||||
|
||||
# GET request - load current config
|
||||
"""Edit configuration page"""
|
||||
try:
|
||||
with open('/etc/haproxy/haproxy.cfg', 'r') as f:
|
||||
config_content = f.read()
|
||||
print(f"[EDIT] Config loaded successfully ({len(config_content)} bytes)", flush=True)
|
||||
except FileNotFoundError:
|
||||
config_content = "# HAProxy configuration file not found\n# Please create /etc/haproxy/haproxy.cfg\n"
|
||||
print(f"[EDIT] Config file not found", flush=True)
|
||||
except PermissionError:
|
||||
config_content = "# Permission denied reading HAProxy configuration file\n"
|
||||
print(f"[EDIT] Permission denied reading config", flush=True)
|
||||
vhosts = VirtualHost.query.all()
|
||||
return render_template('edit.html', vhosts=vhosts)
|
||||
except Exception as e:
|
||||
config_content = f"# Error reading config: {e}\n"
|
||||
print(f"[EDIT] Error reading config: {e}", flush=True)
|
||||
|
||||
return render_template('edit.html', config_content=config_content)
|
||||
logger.error(f"[EDIT] Error: {e}", flush=True)
|
||||
return render_template('edit.html', vhosts=[], error=str(e))
|
||||
|
||||
@@ -1,251 +1,39 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
import subprocess
|
||||
from auth.auth_middleware import requires_auth
|
||||
from utils.haproxy_config import update_haproxy_config, count_frontends_and_backends
|
||||
"""Main routes - Dashboard, Home"""
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, session
|
||||
from database.models import VirtualHost
|
||||
from routes.auth_routes import login_required
|
||||
import logging
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def reload_haproxy():
|
||||
"""Reload HAProxy by killing it - supervisord restarts automatically"""
|
||||
try:
|
||||
# Validate config first
|
||||
result = subprocess.run(
|
||||
['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Config validation failed: {result.stdout}"
|
||||
|
||||
# Kill haproxy - supervisord will restart it automatically
|
||||
result = subprocess.run(
|
||||
['pkill', '-f', 'haproxy'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0 or 'No such process' in result.stdout:
|
||||
print("[HAPROXY] Process killed, supervisord will restart", flush=True)
|
||||
return True, "HAProxy restarted successfully"
|
||||
else:
|
||||
print(f"[HAPROXY] pkill failed: {result.stdout}", flush=True)
|
||||
return False, f"pkill failed: {result.stdout}"
|
||||
except Exception as e:
|
||||
print(f"[HAPROXY] Error: {e}", flush=True)
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
@main_bp.route('/', methods=['GET', 'POST'])
|
||||
@requires_auth
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if request.method == 'POST':
|
||||
# Frontend IP i port
|
||||
frontend_ip = request.form['frontend_ip']
|
||||
frontend_port = request.form['frontend_port']
|
||||
frontend_hostname = request.form.get('frontend_hostname', '').strip()
|
||||
|
||||
lb_method = request.form['lb_method']
|
||||
protocol = request.form['protocol']
|
||||
backend_name = request.form['backend_name']
|
||||
|
||||
# Header options
|
||||
add_header = 'add_header' in request.form
|
||||
header_name = request.form.get('header_name', '') if add_header else ''
|
||||
header_value = request.form.get('header_value', '') if add_header else ''
|
||||
|
||||
# Server header removal
|
||||
del_server_header = 'del_server_header' in request.form
|
||||
|
||||
backend_ssl_redirect = 'backend_ssl_redirect' in request.form
|
||||
ssl_redirect_backend_name = request.form.get('ssl_redirect_backend_name', '').strip() if backend_ssl_redirect else ''
|
||||
ssl_redirect_port = request.form.get('ssl_redirect_port', '80') # ✅ POBIERA PORT Z FORMU
|
||||
|
||||
# Backend servers
|
||||
backend_server_names = request.form.getlist('backend_server_names[]')
|
||||
backend_server_ips = request.form.getlist('backend_server_ips[]')
|
||||
backend_server_ports = request.form.getlist('backend_server_ports[]')
|
||||
backend_server_maxconns = request.form.getlist('backend_server_maxconns[]')
|
||||
|
||||
# Custom ACL
|
||||
add_custom_acl = 'add_custom_acl' in request.form
|
||||
custom_acl_name = request.form.get('custom_acl_name', '').strip() if add_custom_acl else ''
|
||||
custom_acl_type = request.form.get('custom_acl_type', 'path_beg') if add_custom_acl else ''
|
||||
custom_acl_value = request.form.get('custom_acl_value', '').strip() if add_custom_acl else ''
|
||||
custom_acl_action = request.form.get('custom_acl_action', 'route') if add_custom_acl else ''
|
||||
custom_acl_backend = request.form.get('custom_acl_backend', '').strip() if add_custom_acl else ''
|
||||
custom_acl_redirect_url = request.form.get('custom_acl_redirect_url', '').strip() if add_custom_acl else ''
|
||||
|
||||
# SSL
|
||||
use_ssl = 'ssl_checkbox' in request.form
|
||||
ssl_cert_path = request.form.get('ssl_cert_path', '/app/ssl/haproxy-configurator.pem')
|
||||
https_redirect = 'ssl_redirect_checkbox' in request.form
|
||||
|
||||
# DOS Protection
|
||||
is_dos = 'add_dos' in request.form
|
||||
ban_duration = request.form.get('ban_duration', '30m')
|
||||
limit_requests = request.form.get('limit_requests', '100')
|
||||
|
||||
# Forward For
|
||||
forward_for = 'forward_for_check' in request.form
|
||||
|
||||
# SQL Injection
|
||||
sql_injection_check = 'sql_injection_check' in request.form
|
||||
|
||||
# XSS
|
||||
is_xss = 'xss_check' in request.form
|
||||
|
||||
# Remote uploads
|
||||
is_remote_upload = 'remote_uploads_check' in request.form
|
||||
|
||||
# Webshells
|
||||
is_webshells = 'webshells_check' in request.form
|
||||
|
||||
# Path-based redirects (legacy)
|
||||
add_path_based = 'add_path_based' in request.form
|
||||
redirect_domain_name = request.form.get('redirect_domain_name', '')
|
||||
root_redirect = request.form.get('root_redirect', '')
|
||||
redirect_to = request.form.get('redirect_to', '')
|
||||
|
||||
# Forbidden paths (legacy)
|
||||
is_forbidden_path = 'add_acl_path' in request.form
|
||||
forbidden_name = request.form.get('forbidden_name', '')
|
||||
allowed_ip = request.form.get('allowed_ip', '')
|
||||
forbidden_path = request.form.get('forbidden_path', '')
|
||||
|
||||
# Build backend_servers list
|
||||
backend_servers = []
|
||||
for i in range(len(backend_server_ips)):
|
||||
name = backend_server_names[i] if i < len(backend_server_names) else f"server{i+1}"
|
||||
ip = backend_server_ips[i] if i < len(backend_server_ips) else ''
|
||||
port = backend_server_ports[i] if i < len(backend_server_ports) else ''
|
||||
maxconn = backend_server_maxconns[i] if i < len(backend_server_maxconns) else None
|
||||
if ip and port:
|
||||
backend_servers.append((name, ip, port, maxconn))
|
||||
|
||||
# Health checks
|
||||
health_check = False
|
||||
health_check_link = ""
|
||||
if protocol == 'http':
|
||||
health_check = 'health_check' in request.form
|
||||
if health_check:
|
||||
health_check_link = request.form.get('health_check_link', '/')
|
||||
|
||||
health_check_tcp = False
|
||||
if protocol == 'tcp':
|
||||
health_check_tcp = 'health_check2' in request.form
|
||||
|
||||
# Sticky session
|
||||
sticky_session = False
|
||||
sticky_session_type = ""
|
||||
if 'sticky_session' in request.form:
|
||||
sticky_session = True
|
||||
sticky_session_type = request.form.get('sticky_session_type', 'cookie')
|
||||
|
||||
# Legacy ACL (unused, kept for compatibility)
|
||||
is_acl = False
|
||||
acl_name = ''
|
||||
acl_action = ''
|
||||
acl_backend_name = ''
|
||||
|
||||
# Frontend name (None - will be generated)
|
||||
frontend_name = None
|
||||
|
||||
# Call update_haproxy_config
|
||||
message = update_haproxy_config(
|
||||
frontend_name=frontend_name,
|
||||
frontend_ip=frontend_ip,
|
||||
frontend_port=frontend_port,
|
||||
lb_method=lb_method,
|
||||
protocol=protocol,
|
||||
backend_name=backend_name,
|
||||
backend_servers=backend_servers,
|
||||
health_check=health_check,
|
||||
health_check_tcp=health_check_tcp,
|
||||
health_check_link=health_check_link,
|
||||
sticky_session=sticky_session,
|
||||
add_header=add_header,
|
||||
header_name=header_name,
|
||||
header_value=header_value,
|
||||
sticky_session_type=sticky_session_type,
|
||||
is_acl=is_acl,
|
||||
acl_name=acl_name,
|
||||
acl_action=acl_action,
|
||||
acl_backend_name=acl_backend_name,
|
||||
use_ssl=use_ssl,
|
||||
ssl_cert_path=ssl_cert_path,
|
||||
https_redirect=https_redirect,
|
||||
is_dos=is_dos,
|
||||
ban_duration=ban_duration,
|
||||
limit_requests=limit_requests,
|
||||
forward_for=forward_for,
|
||||
is_forbidden_path=is_forbidden_path,
|
||||
forbidden_name=forbidden_name,
|
||||
allowed_ip=allowed_ip,
|
||||
forbidden_path=forbidden_path,
|
||||
sql_injection_check=sql_injection_check,
|
||||
is_xss=is_xss,
|
||||
is_remote_upload=is_remote_upload,
|
||||
add_path_based=add_path_based,
|
||||
redirect_domain_name=redirect_domain_name,
|
||||
root_redirect=root_redirect,
|
||||
redirect_to=redirect_to,
|
||||
is_webshells=is_webshells,
|
||||
del_server_header=del_server_header,
|
||||
backend_ssl_redirect=backend_ssl_redirect,
|
||||
ssl_redirect_backend_name=ssl_redirect_backend_name,
|
||||
ssl_redirect_port=ssl_redirect_port,
|
||||
frontend_hostname=frontend_hostname,
|
||||
add_custom_acl=add_custom_acl,
|
||||
custom_acl_name=custom_acl_name,
|
||||
custom_acl_type=custom_acl_type,
|
||||
custom_acl_value=custom_acl_value,
|
||||
custom_acl_action=custom_acl_action,
|
||||
custom_acl_backend=custom_acl_backend,
|
||||
custom_acl_redirect_url=custom_acl_redirect_url
|
||||
)
|
||||
|
||||
# ===== DETERMINE MESSAGE TYPE =====
|
||||
message_type = "success" # Default
|
||||
|
||||
# Check for ERROR conditions
|
||||
if "error" in message.lower():
|
||||
message_type = "danger"
|
||||
elif "failed" in message.lower():
|
||||
message_type = "danger"
|
||||
elif "already exists" in message.lower():
|
||||
message_type = "danger"
|
||||
elif "cannot add" in message.lower():
|
||||
message_type = "danger"
|
||||
# SUCCESS conditions
|
||||
elif "configuration updated successfully" in message.lower():
|
||||
message_type = "success"
|
||||
elif "backend added to existing" in message.lower():
|
||||
message_type = "success"
|
||||
|
||||
# ===== RELOAD HAPROXY (JEŚLI SUCCESS) =====
|
||||
if message_type == "success":
|
||||
reload_ok, reload_msg = reload_haproxy()
|
||||
if reload_ok:
|
||||
message = message + " ✓ " + reload_msg
|
||||
message_type = "success"
|
||||
else:
|
||||
message = message + " ⚠ " + reload_msg
|
||||
message_type = "warning"
|
||||
|
||||
return render_template('index.html',
|
||||
message=message,
|
||||
message_type=message_type)
|
||||
"""Dashboard - list vhosts"""
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request - display stats
|
||||
frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends()
|
||||
|
||||
return render_template('index.html',
|
||||
frontend_count=frontend_count,
|
||||
backend_count=backend_count,
|
||||
acl_count=acl_count,
|
||||
layer7_count=layer7_count,
|
||||
layer4_count=layer4_count)
|
||||
return render_template('dashboard.html')
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
"""Home - alias for dashboard"""
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
|
||||
@main_bp.route('/display_logs')
|
||||
@login_required
|
||||
def display_logs():
|
||||
"""Display HAProxy logs"""
|
||||
return render_template('logs.html')
|
||||
|
||||
|
||||
@main_bp.route('/display_haproxy_stats')
|
||||
@login_required
|
||||
def display_haproxy_stats():
|
||||
"""Display HAProxy statistics"""
|
||||
return render_template('statistics.html')
|
||||
|
||||
211
routes/user_routes.py
Normal file
211
routes/user_routes.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""User management routes - Admin only"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from functools import wraps
|
||||
from database import db
|
||||
from database.models import User
|
||||
from routes.auth_routes import admin_required
|
||||
import logging
|
||||
|
||||
user_bp = Blueprint('users', __name__, url_prefix='/api/users')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def api_admin_required(f):
|
||||
"""Decorator for API admin requirement"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'error': 'Not authenticated'}), 401
|
||||
|
||||
user = User.query.get(session['user_id'])
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@user_bp.route('', methods=['GET'])
|
||||
@api_admin_required
|
||||
def list_users():
|
||||
"""List all users"""
|
||||
try:
|
||||
users = User.query.all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'users': [{
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'is_admin': u.is_admin,
|
||||
'created_at': u.created_at.isoformat(),
|
||||
'last_login': u.last_login.isoformat() if u.last_login else None
|
||||
} for u in users]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[USERS] Error listing users: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@user_bp.route('', methods=['POST'])
|
||||
@api_admin_required
|
||||
def create_user():
|
||||
"""Create new user"""
|
||||
try:
|
||||
data = request.json
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
is_admin = data.get('is_admin', False)
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password required', 'success': False}), 400
|
||||
|
||||
if len(username) < 3:
|
||||
return jsonify({'error': 'Username must be at least 3 characters', 'success': False}), 400
|
||||
|
||||
if len(password) < 6:
|
||||
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
|
||||
|
||||
# Check if exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': 'Username already exists', 'success': False}), 400
|
||||
|
||||
user = User(username=username, is_admin=is_admin)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[USERS] Created user '{username}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'is_admin': user.is_admin,
|
||||
'message': 'User created successfully'
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[USERS] Error creating user: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@user_bp.route('/<int:user_id>', methods=['PUT'])
|
||||
@api_admin_required
|
||||
def update_user(user_id):
|
||||
"""Update user"""
|
||||
try:
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'success': False}), 404
|
||||
|
||||
data = request.json
|
||||
|
||||
# Don't allow self-demotion
|
||||
if user.id == session['user_id'] and 'is_admin' in data and not data['is_admin']:
|
||||
return jsonify({'error': 'Cannot remove admin role from yourself', 'success': False}), 400
|
||||
|
||||
if 'is_admin' in data:
|
||||
user.is_admin = data['is_admin']
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
password = data['password'].strip()
|
||||
if len(password) < 6:
|
||||
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
|
||||
user.set_password(password)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[USERS] Updated user '{user.username}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'User updated successfully',
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'is_admin': user.is_admin
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[USERS] Error updating user: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@user_bp.route('/<int:user_id>', methods=['DELETE'])
|
||||
@api_admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete user"""
|
||||
try:
|
||||
# Don't allow self-deletion
|
||||
if user_id == session['user_id']:
|
||||
return jsonify({'error': 'Cannot delete your own account', 'success': False}), 400
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'success': False}), 404
|
||||
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[USERS] Deleted user '{username}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'User {username} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[USERS] Error deleting user: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@user_bp.route('/<int:user_id>/change-password', methods=['POST'])
|
||||
def change_password(user_id):
|
||||
"""Change own password"""
|
||||
try:
|
||||
# Check if logged in
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'error': 'Not authenticated', 'success': False}), 401
|
||||
|
||||
# Can only change own password, or admin can change others
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'success': False}), 404
|
||||
|
||||
is_admin = session.get('is_admin', False)
|
||||
is_own_account = session['user_id'] == user_id
|
||||
|
||||
if not is_own_account and not is_admin:
|
||||
return jsonify({'error': 'Forbidden', 'success': False}), 403
|
||||
|
||||
data = request.json
|
||||
|
||||
# If changing own password, require old password
|
||||
if is_own_account:
|
||||
old_password = data.get('old_password', '').strip()
|
||||
if not user.check_password(old_password):
|
||||
return jsonify({'error': 'Current password is incorrect', 'success': False}), 400
|
||||
|
||||
new_password = data.get('new_password', '').strip()
|
||||
if len(new_password) < 6:
|
||||
return jsonify({'error': 'Password must be at least 6 characters', 'success': False}), 400
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"[USERS] Password changed for '{user.username}'", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Password changed successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[USERS] Error changing password: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
523
routes/vhost_routes.py
Normal file
523
routes/vhost_routes.py
Normal file
@@ -0,0 +1,523 @@
|
||||
"""Virtual Host Management - RESTful API"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from functools import wraps
|
||||
from database import db
|
||||
from database.models import VirtualHost, BackendServer, ConfigHistory, Certificate
|
||||
from utils.config_generator import generate_haproxy_config, reload_haproxy
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
vhost_bp = Blueprint('vhosts', __name__, url_prefix='/api/vhosts')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_required_api(f):
|
||||
"""API version of login required"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'error': 'Not authenticated', 'success': False}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@vhost_bp.route('', methods=['GET'])
|
||||
@login_required_api
|
||||
def list_vhosts():
|
||||
"""Get all virtual hosts"""
|
||||
try:
|
||||
vhosts = VirtualHost.query.order_by(VirtualHost.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'vhosts': [{
|
||||
'id': v.id,
|
||||
'name': v.name,
|
||||
'hostname': v.hostname,
|
||||
'frontend_ip': v.frontend_ip,
|
||||
'frontend_port': v.frontend_port,
|
||||
'protocol': v.protocol,
|
||||
'use_ssl': v.use_ssl,
|
||||
'lb_method': v.lb_method,
|
||||
'enabled': v.enabled,
|
||||
'backend_count': len(v.backend_servers),
|
||||
'created_at': v.created_at.isoformat(),
|
||||
'updated_at': v.updated_at.isoformat() if v.updated_at else None
|
||||
} for v in vhosts]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[VHOSTS] Error listing: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>', methods=['GET'])
|
||||
@login_required_api
|
||||
def get_vhost(vhost_id):
|
||||
"""Get single vhost with backend servers"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'vhost': {
|
||||
'id': vhost.id,
|
||||
'name': vhost.name,
|
||||
'hostname': vhost.hostname,
|
||||
'description': vhost.description,
|
||||
'frontend_ip': vhost.frontend_ip,
|
||||
'frontend_port': vhost.frontend_port,
|
||||
'protocol': vhost.protocol,
|
||||
'use_ssl': vhost.use_ssl,
|
||||
'certificate_id': vhost.certificate_id,
|
||||
'ssl_redirect': vhost.ssl_redirect,
|
||||
'ssl_redirect_port': vhost.ssl_redirect_port,
|
||||
'lb_method': vhost.lb_method,
|
||||
'dos_protection': vhost.dos_protection,
|
||||
'dos_ban_duration': vhost.dos_ban_duration,
|
||||
'dos_limit_requests': vhost.dos_limit_requests,
|
||||
'sql_injection_check': vhost.sql_injection_check,
|
||||
'xss_check': vhost.xss_check,
|
||||
'webshell_check': vhost.webshell_check,
|
||||
'add_custom_header': vhost.add_custom_header,
|
||||
'custom_header_name': vhost.custom_header_name,
|
||||
'custom_header_value': vhost.custom_header_value,
|
||||
'del_server_header': vhost.del_server_header,
|
||||
'forward_for': vhost.forward_for,
|
||||
'enabled': vhost.enabled,
|
||||
'backend_servers': [{
|
||||
'id': bs.id,
|
||||
'name': bs.name,
|
||||
'ip_address': bs.ip_address,
|
||||
'port': bs.port,
|
||||
'maxconn': bs.maxconn,
|
||||
'weight': bs.weight,
|
||||
'health_check': bs.health_check,
|
||||
'health_check_path': bs.health_check_path,
|
||||
'enabled': bs.enabled
|
||||
} for bs in vhost.backend_servers]
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[VHOSTS] Error getting vhost {vhost_id}: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('', methods=['POST'])
|
||||
@login_required_api
|
||||
def create_vhost():
|
||||
"""Create new virtual host"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# Validate required fields
|
||||
required = ['name', 'hostname', 'frontend_port']
|
||||
for field in required:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'{field} is required', 'success': False}), 400
|
||||
|
||||
# Check if name already exists
|
||||
if VirtualHost.query.filter_by(name=data['name']).first():
|
||||
return jsonify({'error': 'VHost name already exists', 'success': False}), 400
|
||||
|
||||
# Create vhost
|
||||
vhost = VirtualHost(
|
||||
name=data['name'].strip(),
|
||||
hostname=data['hostname'].strip(),
|
||||
description=data.get('description', '').strip(),
|
||||
frontend_ip=data.get('frontend_ip', '0.0.0.0'),
|
||||
frontend_port=int(data['frontend_port']),
|
||||
protocol=data.get('protocol', 'http'),
|
||||
use_ssl=data.get('use_ssl', False),
|
||||
certificate_id=data.get('certificate_id'),
|
||||
ssl_redirect=data.get('ssl_redirect', False),
|
||||
ssl_redirect_port=int(data.get('ssl_redirect_port', 80)),
|
||||
lb_method=data.get('lb_method', 'roundrobin'),
|
||||
dos_protection=data.get('dos_protection', False),
|
||||
dos_ban_duration=data.get('dos_ban_duration', '30m'),
|
||||
dos_limit_requests=int(data.get('dos_limit_requests', 100)),
|
||||
sql_injection_check=data.get('sql_injection_check', False),
|
||||
xss_check=data.get('xss_check', False),
|
||||
webshell_check=data.get('webshell_check', False),
|
||||
add_custom_header=data.get('add_custom_header', False),
|
||||
custom_header_name=data.get('custom_header_name', ''),
|
||||
custom_header_value=data.get('custom_header_value', ''),
|
||||
del_server_header=data.get('del_server_header', False),
|
||||
forward_for=data.get('forward_for', True),
|
||||
enabled=data.get('enabled', True)
|
||||
)
|
||||
|
||||
db.session.add(vhost)
|
||||
db.session.flush()
|
||||
|
||||
# Add backend servers if provided
|
||||
if data.get('backend_servers'):
|
||||
for bs_data in data['backend_servers']:
|
||||
backend = BackendServer(
|
||||
vhost_id=vhost.id,
|
||||
name=bs_data.get('name', f'server_{bs_data.get("ip_address")}'),
|
||||
ip_address=bs_data['ip_address'],
|
||||
port=int(bs_data['port']),
|
||||
maxconn=bs_data.get('maxconn'),
|
||||
weight=int(bs_data.get('weight', 1)),
|
||||
health_check=bs_data.get('health_check', False),
|
||||
health_check_path=bs_data.get('health_check_path', '/'),
|
||||
enabled=bs_data.get('enabled', True)
|
||||
)
|
||||
db.session.add(backend)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Save config history
|
||||
config_history = ConfigHistory(
|
||||
config_content=generate_haproxy_config(),
|
||||
change_type='vhost_create',
|
||||
vhost_id=vhost.id,
|
||||
user_id=session['user_id'],
|
||||
description=f"Created VHost: {vhost.name}"
|
||||
)
|
||||
db.session.add(config_history)
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[VHOSTS] Created VHost '{vhost.name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': vhost.id,
|
||||
'name': vhost.name,
|
||||
'message': 'VHost created successfully'
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[VHOSTS] Error creating vhost: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>', methods=['PUT'])
|
||||
@login_required_api
|
||||
def update_vhost(vhost_id):
|
||||
"""Update virtual host"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
data = request.json
|
||||
|
||||
# Update basic fields
|
||||
if 'name' in data:
|
||||
# Check if name is taken
|
||||
existing = VirtualHost.query.filter_by(name=data['name']).filter(VirtualHost.id != vhost_id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'VHost name already exists', 'success': False}), 400
|
||||
vhost.name = data['name'].strip()
|
||||
|
||||
if 'hostname' in data:
|
||||
vhost.hostname = data['hostname'].strip()
|
||||
if 'description' in data:
|
||||
vhost.description = data['description'].strip()
|
||||
if 'frontend_ip' in data:
|
||||
vhost.frontend_ip = data['frontend_ip']
|
||||
if 'frontend_port' in data:
|
||||
vhost.frontend_port = int(data['frontend_port'])
|
||||
if 'protocol' in data:
|
||||
vhost.protocol = data['protocol']
|
||||
if 'use_ssl' in data:
|
||||
vhost.use_ssl = data['use_ssl']
|
||||
if 'certificate_id' in data:
|
||||
vhost.certificate_id = data['certificate_id']
|
||||
if 'ssl_redirect' in data:
|
||||
vhost.ssl_redirect = data['ssl_redirect']
|
||||
if 'ssl_redirect_port' in data:
|
||||
vhost.ssl_redirect_port = int(data['ssl_redirect_port'])
|
||||
if 'lb_method' in data:
|
||||
vhost.lb_method = data['lb_method']
|
||||
|
||||
# Security settings
|
||||
if 'dos_protection' in data:
|
||||
vhost.dos_protection = data['dos_protection']
|
||||
if 'dos_ban_duration' in data:
|
||||
vhost.dos_ban_duration = data['dos_ban_duration']
|
||||
if 'dos_limit_requests' in data:
|
||||
vhost.dos_limit_requests = int(data['dos_limit_requests'])
|
||||
if 'sql_injection_check' in data:
|
||||
vhost.sql_injection_check = data['sql_injection_check']
|
||||
if 'xss_check' in data:
|
||||
vhost.xss_check = data['xss_check']
|
||||
if 'webshell_check' in data:
|
||||
vhost.webshell_check = data['webshell_check']
|
||||
|
||||
# Header settings
|
||||
if 'add_custom_header' in data:
|
||||
vhost.add_custom_header = data['add_custom_header']
|
||||
if 'custom_header_name' in data:
|
||||
vhost.custom_header_name = data['custom_header_name']
|
||||
if 'custom_header_value' in data:
|
||||
vhost.custom_header_value = data['custom_header_value']
|
||||
if 'del_server_header' in data:
|
||||
vhost.del_server_header = data['del_server_header']
|
||||
if 'forward_for' in data:
|
||||
vhost.forward_for = data['forward_for']
|
||||
|
||||
if 'enabled' in data:
|
||||
vhost.enabled = data['enabled']
|
||||
|
||||
vhost.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Save config history
|
||||
config_history = ConfigHistory(
|
||||
config_content=generate_haproxy_config(),
|
||||
change_type='vhost_edit',
|
||||
vhost_id=vhost.id,
|
||||
user_id=session['user_id'],
|
||||
description=f"Updated VHost: {vhost.name}"
|
||||
)
|
||||
db.session.add(config_history)
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[VHOSTS] Updated VHost '{vhost.name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'VHost updated successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[VHOSTS] Error updating vhost {vhost_id}: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>', methods=['DELETE'])
|
||||
@login_required_api
|
||||
def delete_vhost(vhost_id):
|
||||
"""Delete virtual host"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
vhost_name = vhost.name
|
||||
|
||||
# Save config history before deletion
|
||||
config_history = ConfigHistory(
|
||||
config_content=generate_haproxy_config(),
|
||||
change_type='vhost_delete',
|
||||
vhost_id=vhost.id,
|
||||
user_id=session['user_id'],
|
||||
description=f"Deleted VHost: {vhost_name}"
|
||||
)
|
||||
db.session.add(config_history)
|
||||
|
||||
# Delete vhost (cascades to backend servers)
|
||||
db.session.delete(vhost)
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[VHOSTS] Deleted VHost '{vhost_name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'VHost {vhost_name} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[VHOSTS] Error deleting vhost {vhost_id}: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>/toggle', methods=['POST'])
|
||||
@login_required_api
|
||||
def toggle_vhost(vhost_id):
|
||||
"""Toggle vhost enabled/disabled"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
vhost.enabled = not vhost.enabled
|
||||
vhost.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[VHOSTS] Toggled VHost '{vhost.name}' to {vhost.enabled} by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'enabled': vhost.enabled,
|
||||
'message': f"VHost {'enabled' if vhost.enabled else 'disabled'}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[VHOSTS] Error toggling vhost {vhost_id}: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
# ===== BACKEND SERVERS =====
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>/servers', methods=['GET'])
|
||||
@login_required_api
|
||||
def get_vhost_servers(vhost_id):
|
||||
"""Get all backend servers for vhost"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'servers': [{
|
||||
'id': bs.id,
|
||||
'name': bs.name,
|
||||
'ip_address': bs.ip_address,
|
||||
'port': bs.port,
|
||||
'maxconn': bs.maxconn,
|
||||
'weight': bs.weight,
|
||||
'health_check': bs.health_check,
|
||||
'health_check_path': bs.health_check_path,
|
||||
'enabled': bs.enabled
|
||||
} for bs in vhost.backend_servers]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[SERVERS] Error getting servers: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/<int:vhost_id>/servers', methods=['POST'])
|
||||
@login_required_api
|
||||
def add_backend_server(vhost_id):
|
||||
"""Add backend server to vhost"""
|
||||
try:
|
||||
vhost = VirtualHost.query.get(vhost_id)
|
||||
if not vhost:
|
||||
return jsonify({'error': 'VHost not found', 'success': False}), 404
|
||||
|
||||
data = request.json
|
||||
|
||||
if not data.get('ip_address') or not data.get('port'):
|
||||
return jsonify({'error': 'IP address and port required', 'success': False}), 400
|
||||
|
||||
server = BackendServer(
|
||||
vhost_id=vhost_id,
|
||||
name=data.get('name', f"server_{data['ip_address']}"),
|
||||
ip_address=data['ip_address'],
|
||||
port=int(data['port']),
|
||||
maxconn=data.get('maxconn'),
|
||||
weight=int(data.get('weight', 1)),
|
||||
health_check=data.get('health_check', False),
|
||||
health_check_path=data.get('health_check_path', '/'),
|
||||
enabled=data.get('enabled', True)
|
||||
)
|
||||
|
||||
db.session.add(server)
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[SERVERS] Added server to VHost '{vhost.name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'id': server.id,
|
||||
'message': 'Backend server added'
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SERVERS] Error adding server: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/servers/<int:server_id>', methods=['PUT'])
|
||||
@login_required_api
|
||||
def update_backend_server(server_id):
|
||||
"""Update backend server"""
|
||||
try:
|
||||
server = BackendServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({'error': 'Server not found', 'success': False}), 404
|
||||
|
||||
data = request.json
|
||||
|
||||
if 'name' in data:
|
||||
server.name = data['name']
|
||||
if 'ip_address' in data:
|
||||
server.ip_address = data['ip_address']
|
||||
if 'port' in data:
|
||||
server.port = int(data['port'])
|
||||
if 'maxconn' in data:
|
||||
server.maxconn = data['maxconn']
|
||||
if 'weight' in data:
|
||||
server.weight = int(data['weight'])
|
||||
if 'health_check' in data:
|
||||
server.health_check = data['health_check']
|
||||
if 'health_check_path' in data:
|
||||
server.health_check_path = data['health_check_path']
|
||||
if 'enabled' in data:
|
||||
server.enabled = data['enabled']
|
||||
|
||||
server.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[SERVERS] Updated server '{server.name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Backend server updated'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SERVERS] Error updating server: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
|
||||
|
||||
@vhost_bp.route('/servers/<int:server_id>', methods=['DELETE'])
|
||||
@login_required_api
|
||||
def delete_backend_server(server_id):
|
||||
"""Delete backend server"""
|
||||
try:
|
||||
server = BackendServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({'error': 'Server not found', 'success': False}), 404
|
||||
|
||||
server_name = server.name
|
||||
vhost_id = server.vhost_id
|
||||
|
||||
db.session.delete(server)
|
||||
db.session.commit()
|
||||
|
||||
# Reload HAProxy
|
||||
reload_haproxy()
|
||||
|
||||
logger.info(f"[SERVERS] Deleted server '{server_name}' by {session.get('username')}", flush=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Backend server {server_name} deleted'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"[SERVERS] Error deleting server: {e}", flush=True)
|
||||
return jsonify({'error': str(e), 'success': False}), 500
|
||||
219
static/js/cert_manager.js
Normal file
219
static/js/cert_manager.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Certificate Manager - Upload, List, Delete
|
||||
*/
|
||||
|
||||
let currentCertId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCertificates();
|
||||
|
||||
document.getElementById('uploadBtn').addEventListener('click', uploadCertificate);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportCertificate);
|
||||
});
|
||||
|
||||
function loadCertificates() {
|
||||
fetch('/api/certificates')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderCertificates(data.certificates);
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Error loading certificates:', e));
|
||||
}
|
||||
|
||||
function renderCertificates(certs) {
|
||||
const tbody = document.getElementById('certsList');
|
||||
|
||||
if (certs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">No certificates uploaded yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = certs.map(c => {
|
||||
const expiresDate = c.expires_at ? new Date(c.expires_at) : null;
|
||||
const isExpired = c.is_expired;
|
||||
const expiresText = expiresDate ? formatDate(expiresDate) : 'Unknown';
|
||||
|
||||
return `
|
||||
<tr ${isExpired ? 'class="table-danger"' : ''}>
|
||||
<td><strong>${escapeHtml(c.name)}</strong></td>
|
||||
<td><code>${escapeHtml(c.common_name || 'N/A')}</code></td>
|
||||
<td>
|
||||
${isExpired ? '<span class="badge bg-danger">EXPIRED</span>' : ''}
|
||||
<small>${expiresText}</small>
|
||||
</td>
|
||||
<td>
|
||||
${c.vhost_count > 0
|
||||
? `<span class="badge bg-info">${c.vhost_count}</span>`
|
||||
: '<span class="text-muted">Not used</span>'}
|
||||
</td>
|
||||
<td>
|
||||
${isExpired
|
||||
? '<span class="badge bg-danger">Expired</span>'
|
||||
: '<span class="badge bg-success">Valid</span>'}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-info" onclick="viewDetails(${c.id})">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</button>
|
||||
${c.vhost_count === 0 ? `
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteCert(${c.id}, '${escapeHtml(c.name)}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function uploadCertificate() {
|
||||
const name = document.getElementById('cert_name').value;
|
||||
const file = document.getElementById('cert_file').files[0];
|
||||
|
||||
if (!name || !file) {
|
||||
showAlert('Certificate name and file are required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('cert_file', file);
|
||||
|
||||
fetch('/api/certificates', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
|
||||
document.getElementById('uploadForm').reset();
|
||||
loadCertificates();
|
||||
showAlert('Certificate uploaded successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function viewDetails(certId) {
|
||||
currentCertId = certId;
|
||||
|
||||
fetch(`/api/certificates/${certId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const c = data.certificate;
|
||||
document.getElementById('detailsName').textContent = c.name;
|
||||
|
||||
const san = c.subject_alt_names && c.subject_alt_names.length > 0
|
||||
? c.subject_alt_names.join(', ')
|
||||
: 'No SAN';
|
||||
|
||||
const vhosts = c.vhosts && c.vhosts.length > 0
|
||||
? c.vhosts.map(v => `<li>${v.name} (${v.hostname})</li>`).join('')
|
||||
: '<li class="text-muted">Not used</li>';
|
||||
|
||||
const detailsHTML = `
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th>Common Name</th>
|
||||
<td><code>${escapeHtml(c.common_name || 'N/A')}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Subject Alt Names</th>
|
||||
<td><code>${escapeHtml(san)}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Issued</th>
|
||||
<td>${c.issued_at ? formatDate(new Date(c.issued_at)) : 'Unknown'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Expires</th>
|
||||
<td>
|
||||
${c.expires_at ? formatDate(new Date(c.expires_at)) : 'Unknown'}
|
||||
${new Date(c.expires_at) < new Date() ? '<span class="badge bg-danger ms-2">EXPIRED</span>' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<td>${formatDate(new Date(c.created_at))}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h6>Used by VHosts:</h6>
|
||||
<ul>${vhosts}</ul>
|
||||
`;
|
||||
|
||||
document.getElementById('detailsContent').innerHTML = detailsHTML;
|
||||
new bootstrap.Modal(document.getElementById('detailsModal')).show();
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function exportCertificate() {
|
||||
fetch(`/api/certificates/${currentCertId}/export`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Create download link
|
||||
const blob = new Blob([data.content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${data.name}.pem`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function deleteCert(certId, name) {
|
||||
if (!confirm(`Delete certificate '${name}'? This cannot be undone.`)) return;
|
||||
|
||||
fetch(`/api/certificates/${certId}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadCertificates();
|
||||
showAlert('Certificate deleted successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.card').parentElement.insertBefore(alertDiv, document.querySelector('.card'));
|
||||
setTimeout(() => alertDiv.remove(), 5000);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
156
static/js/user_manager.js
Normal file
156
static/js/user_manager.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* User Management UI
|
||||
*/
|
||||
|
||||
let currentEditUserId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUsers();
|
||||
|
||||
document.getElementById('createUserBtn').addEventListener('click', createUser);
|
||||
document.getElementById('updateUserBtn').addEventListener('click', updateUser);
|
||||
});
|
||||
|
||||
function loadUsers() {
|
||||
fetch('/api/users')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderUsers(data.users);
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Error loading users:', e));
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const tbody = document.getElementById('usersList');
|
||||
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = users.map(user => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(user.username)}</strong></td>
|
||||
<td>
|
||||
${user.is_admin ? '<span class="badge bg-danger">Admin</span>' : '<span class="badge bg-secondary">User</span>'}
|
||||
</td>
|
||||
<td><small class="text-muted">${formatDate(user.created_at)}</small></td>
|
||||
<td><small class="text-muted">${user.last_login ? formatDate(user.last_login) : 'Never'}</small></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="editUser(${user.id}, '${escapeHtml(user.username)}', ${user.is_admin})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
${user.id !== getUserId() ? `
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
const username = document.getElementById('newUsername').value;
|
||||
const password = document.getElementById('newPassword').value;
|
||||
const isAdmin = document.getElementById('newIsAdmin').checked;
|
||||
|
||||
fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ username, password, is_admin: isAdmin })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('newUserModal')).hide();
|
||||
document.getElementById('newUserForm').reset();
|
||||
loadUsers();
|
||||
showAlert('User created successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function editUser(userId, username, isAdmin) {
|
||||
currentEditUserId = userId;
|
||||
document.getElementById('editUsername').textContent = username;
|
||||
document.getElementById('editIsAdmin').checked = isAdmin;
|
||||
new bootstrap.Modal(document.getElementById('editUserModal')).show();
|
||||
}
|
||||
|
||||
function updateUser() {
|
||||
const password = document.getElementById('editPassword').value;
|
||||
const isAdmin = document.getElementById('editIsAdmin').checked;
|
||||
|
||||
const body = { is_admin: isAdmin };
|
||||
if (password) body.password = password;
|
||||
|
||||
fetch(`/api/users/${currentEditUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('editUserModal')).hide();
|
||||
loadUsers();
|
||||
showAlert('User updated successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function deleteUser(userId, username) {
|
||||
if (!confirm(`Delete user '${username}'?`)) return;
|
||||
|
||||
fetch(`/api/users/${userId}`, {method: 'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadUsers();
|
||||
showAlert('User deleted successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.card-body').prepend(alert);
|
||||
setTimeout(() => alert.remove(), 5000);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
function getUserId() {
|
||||
// Extract from current user endpoint
|
||||
let userId = null;
|
||||
fetch('/api/current-user')
|
||||
.then(r => r.json())
|
||||
.then(data => { userId = data.id; });
|
||||
return userId;
|
||||
}
|
||||
285
static/js/vhost_manager.js
Normal file
285
static/js/vhost_manager.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* VHost Manager - Frontend CRUD Logic
|
||||
*/
|
||||
|
||||
let currentEditVHostId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadVHosts();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('createVHostBtn').addEventListener('click', createVHost);
|
||||
document.getElementById('updateVHostBtn').addEventListener('click', updateVHost);
|
||||
document.getElementById('addServerBtn').addEventListener('click', addServerInput);
|
||||
|
||||
// Event delegation for remove buttons
|
||||
document.getElementById('backendServersContainer').addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('remove-server')) {
|
||||
e.target.closest('.backend-server').remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function loadVHosts() {
|
||||
fetch('/api/vhosts')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderVHosts(data.vhosts);
|
||||
updateStats(data.vhosts);
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Error loading vhosts:', e));
|
||||
}
|
||||
|
||||
function updateStats(vhosts) {
|
||||
document.getElementById('total_vhosts').textContent = vhosts.length;
|
||||
document.getElementById('enabled_vhosts').textContent = vhosts.filter(v => v.enabled).length;
|
||||
document.getElementById('disabled_vhosts').textContent = vhosts.filter(v => !v.enabled).length;
|
||||
document.getElementById('ssl_vhosts').textContent = vhosts.filter(v => v.use_ssl).length;
|
||||
}
|
||||
|
||||
function renderVHosts(vhosts) {
|
||||
const tbody = document.getElementById('vhostsList');
|
||||
|
||||
if (vhosts.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">No VHosts created yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = vhosts.map(v => `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${escapeHtml(v.name)}</strong>
|
||||
${v.use_ssl ? '<span class="badge bg-success ms-2">SSL</span>' : ''}
|
||||
</td>
|
||||
<td>${escapeHtml(v.hostname)}</td>
|
||||
<td>
|
||||
<code>${v.frontend_ip}:${v.frontend_port}</code>
|
||||
</td>
|
||||
<td><span class="badge bg-info">${v.protocol.toUpperCase()}</span></td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">${v.backend_count} servers</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm ${v.enabled ? 'btn-success' : 'btn-warning'}"
|
||||
onclick="toggleVHost(${v.id}, ${v.enabled})">
|
||||
<i class="bi bi-${v.enabled ? 'power' : 'hourglass'}"></i>
|
||||
${v.enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="editVHost(${v.id})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteVHost(${v.id}, '${escapeHtml(v.name)}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function createVHost() {
|
||||
const name = document.getElementById('vhost_name').value;
|
||||
const hostname = document.getElementById('vhost_hostname').value;
|
||||
const port = document.getElementById('vhost_port').value;
|
||||
const protocol = document.getElementById('vhost_protocol').value;
|
||||
const lb_method = document.getElementById('vhost_lb_method').value;
|
||||
const use_ssl = document.getElementById('vhost_ssl').checked;
|
||||
|
||||
// Get backend servers
|
||||
const servers = [];
|
||||
document.querySelectorAll('.backend-server').forEach(el => {
|
||||
const ip = el.querySelector('.backend-ip').value;
|
||||
const port = el.querySelector('.backend-port').value;
|
||||
if (ip && port) {
|
||||
servers.push({ ip_address: ip, port: parseInt(port) });
|
||||
}
|
||||
});
|
||||
|
||||
if (servers.length === 0) {
|
||||
showAlert('At least one backend server is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name, hostname,
|
||||
frontend_port: parseInt(port),
|
||||
protocol, lb_method, use_ssl,
|
||||
backend_servers: servers
|
||||
};
|
||||
|
||||
fetch('/api/vhosts', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('newVHostModal')).hide();
|
||||
document.getElementById('newVHostForm').reset();
|
||||
document.getElementById('backendServersContainer').innerHTML = `
|
||||
<div class="backend-server mb-3 p-3 border rounded">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
loadVHosts();
|
||||
showAlert('VHost created successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function editVHost(vhostId) {
|
||||
currentEditVHostId = vhostId;
|
||||
|
||||
fetch(`/api/vhosts/${vhostId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const v = data.vhost;
|
||||
document.getElementById('editVHostName').textContent = v.name;
|
||||
|
||||
const formContent = `
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">VHost Name</label>
|
||||
<input type="text" class="form-control" id="edit_name" value="${escapeHtml(v.name)}">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Hostname</label>
|
||||
<input type="text" class="form-control" id="edit_hostname" value="${escapeHtml(v.hostname)}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="edit_enabled" ${v.enabled ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="edit_enabled">Enabled</label>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Backend Servers</h6>
|
||||
<div id="editServersContainer">
|
||||
${v.backend_servers.map(bs => `
|
||||
<div class="backend-server mb-3 p-3 border rounded">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="text" class="form-control backend-ip" value="${bs.ip_address}" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="number" class="form-control backend-port" value="${bs.port}" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('editFormContent').innerHTML = formContent;
|
||||
new bootstrap.Modal(document.getElementById('editVHostModal')).show();
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function updateVHost() {
|
||||
const name = document.getElementById('edit_name').value;
|
||||
const hostname = document.getElementById('edit_hostname').value;
|
||||
const enabled = document.getElementById('edit_enabled').checked;
|
||||
|
||||
const payload = { name, hostname, enabled };
|
||||
|
||||
fetch(`/api/vhosts/${currentEditVHostId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('editVHostModal')).hide();
|
||||
loadVHosts();
|
||||
showAlert('VHost updated successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function toggleVHost(vhostId, isEnabled) {
|
||||
fetch(`/api/vhosts/${vhostId}/toggle`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadVHosts();
|
||||
showAlert(data.message, 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function deleteVHost(vhostId, name) {
|
||||
if (!confirm(`Delete VHost '${name}'? This cannot be undone.`)) return;
|
||||
|
||||
fetch(`/api/vhosts/${vhostId}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadVHosts();
|
||||
showAlert('VHost deleted successfully', 'success');
|
||||
} else {
|
||||
showAlert(data.error, 'danger');
|
||||
}
|
||||
})
|
||||
.catch(e => showAlert(e.message, 'danger'));
|
||||
}
|
||||
|
||||
function addServerInput() {
|
||||
const container = document.getElementById('backendServersContainer');
|
||||
const newServer = document.createElement('div');
|
||||
newServer.className = 'backend-server mb-3 p-3 border rounded';
|
||||
newServer.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
|
||||
`;
|
||||
container.appendChild(newServer);
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.card:first-of-type').parentElement.insertBefore(alertDiv, document.querySelector('.card:first-of-type'));
|
||||
setTimeout(() => alertDiv.remove(), 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
16
templates/403.html
Normal file
16
templates/403.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">403</h1>
|
||||
<h2>Access Denied</h2>
|
||||
<p class="text-muted">You don't have permission to access this resource.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
templates/404.html
Normal file
16
templates/404.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p class="text-muted">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
templates/500.html
Normal file
16
templates/500.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="text-center">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p class="text-muted">Something went wrong on our end.</p>
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% set active_page = active_page|default('') %}
|
||||
<!doctype html>
|
||||
<html lang="pl" data-bs-theme="dark">
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -15,46 +15,190 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.user-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
.menu-divider {
|
||||
margin: 0.5rem 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-body">
|
||||
<header class="header1" id="header1">
|
||||
<div class="container d-flex align-items-center justify-content-between flex-wrap gap-2 py-2">
|
||||
<a href="{{ url_for('home') }}" class="d-flex align-items-center text-decoration-none logo text-reset">
|
||||
<h3 class="m-0 d-flex align-items-center gap-2">
|
||||
<i class="fas fa-globe"></i><span>HAProxy Configurator</span>
|
||||
</h3>
|
||||
</a>
|
||||
<nav class="menu d-flex align-items-center gap-1 flex-wrap">
|
||||
<a href="{{ url_for('home') }}" class="menu-link {{ 'active' if request.path.startswith('/home') else '' }}"><i class="bi bi-speedometer"></i> Dashboard</a>
|
||||
<a href="{{ url_for('main.index') }}" class="menu-link {{ 'active' if request.path == '/' else '' }}"><i class="bi bi-plus-circle"></i> Add FE/BE</a>
|
||||
<a href="{{ url_for('edit.edit_haproxy_config') }}" class="menu-link {{ 'active' if request.path.startswith('/edit') else '' }}"><i class="bi bi-pencil-square"></i> Edit Configuration</a>
|
||||
<a href="{{ url_for('display_logs') }}" class="menu-link {{ 'active' if request.path.startswith('/logs') else '' }}"><i class="bi bi-shield-lock"></i> Logs</a>
|
||||
<a href="{{ url_for('display_haproxy_stats') }}" class="menu-link {{ 'active' if request.path.startswith('/statistics') else '' }}"><i class="bi bi-graph-up-arrow"></i> Stats</a>
|
||||
<a href="http://{{ request.host.split(':')[0] }}:8404/stats" class="menu-link" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> HAProxy Stats</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<!-- NAVBAR -->
|
||||
<header class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Logo -->
|
||||
<a href="{% if session.get('user_id') %}{{ url_for('main.index') }}{% else %}{{ url_for('auth.login') }}{% endif %}" class="navbar-brand d-flex align-items-center gap-2">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span>HAProxy Manager</span>
|
||||
</a>
|
||||
|
||||
<main class="container py-4">
|
||||
{% with messages = get_flashed_messages() %}{% if messages %}<div id="_flash_msgs" data-msgs="{{ messages|tojson }}"></div>{% endif %}{% endwith %}
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
<div id="toast-stack" class="toast-container position-fixed top-0 end-0 p-3"></div>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="app-footer border-top">
|
||||
<div class="container d-flex flex-wrap justify-content-between align-items-center py-3 small text-muted">
|
||||
<span>© 2025 HAProxy Configurator</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-code-slash"></i>
|
||||
<span>Based on: <a href="https://github.com/alonz22/haproxy-dashboard">This project</a> | by @linuxiarz.pl </span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Toggle Button (Mobile) -->
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
{% block page_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
{% if session.get('user_id') %}
|
||||
<nav class="navbar-nav ms-auto d-flex align-items-center gap-3">
|
||||
<!-- Menu Links -->
|
||||
<a href="{{ url_for('main.index') }}" class="nav-link {{ 'active' if request.path == '/' or request.path.startswith('/home') else '' }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('main.index') }}" class="nav-link {{ 'active' if request.path.startswith('/add') else '' }}">
|
||||
<i class="bi bi-plus-circle"></i> Add VHost
|
||||
</a>
|
||||
<a href="{{ url_for('edit.edit_haproxy_config') }}" class="nav-link {{ 'active' if request.path.startswith('/edit') else '' }}">
|
||||
<i class="bi bi-pencil-square"></i> Edit Config
|
||||
</a>
|
||||
<a href="{{ url_for('main.display_logs') }}" class="nav-link {{ 'active' if request.path.startswith('/logs') else '' }}">
|
||||
<i class="bi bi-shield-lock"></i> Logs
|
||||
</a>
|
||||
<a href="{{ url_for('main.display_haproxy_stats') }}" class="nav-link {{ 'active' if request.path.startswith('/statistics') else '' }}">
|
||||
<i class="bi bi-graph-up-arrow"></i> Stats
|
||||
</a>
|
||||
<a href="http://{{ request.host.split(':')[0] }}:8404/stats" class="nav-link" target="_blank" rel="noopener">
|
||||
<i class="bi bi-box-arrow-up-right"></i> HAProxy Stats
|
||||
</a>
|
||||
|
||||
|
||||
<!-- User Dropdown Menu -->
|
||||
<div class="nav-item dropdown">
|
||||
<button class="nav-link dropdown-toggle btn btn-link text-decoration-none d-flex align-items-center gap-2" id="userMenu" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<span class="d-none d-lg-inline">{{ session.get('username', 'User') }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end user-menu" aria-labelledby="userMenu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" disabled>
|
||||
<i class="bi bi-person"></i> {{ session.get('username', 'User') }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<!-- Admin Only Menu Items -->
|
||||
{% if session.get('is_admin') %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-people"></i> User Management
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('main.index') }}">
|
||||
<i class="bi bi-shield-check"></i> Certificates
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-warning" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
{% else %}
|
||||
<!-- Login Link (when not authenticated) -->
|
||||
<nav class="navbar-nav ms-auto">
|
||||
<a href="{{ url_for('auth.login') }}" class="nav-link">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main class="container-fluid py-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-{{ 'check-circle' if category == 'success' else 'exclamation-circle' if category == 'danger' else 'info-circle' }}"></i>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-stack" class="toast-container position-fixed top-0 end-0 p-3"></div>
|
||||
|
||||
|
||||
<!-- Page Content -->
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="app-footer border-top border-secondary bg-dark mt-5">
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 small text-muted">
|
||||
<p class="mb-2">
|
||||
© 2025 <strong>HAProxy Configurator & Manager</strong>
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Powerful web-based HAProxy configuration management system
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end small text-muted text-start">
|
||||
<p class="mb-2">
|
||||
<i class="bi bi-code-slash"></i>
|
||||
Built with <strong>Flask</strong> + <strong>SQLAlchemy</strong> + <strong>Bootstrap 5</strong>
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Based on: <a href="https://github.com/alonz22/haproxy-dashboard" target="_blank" class="text-decoration-none" rel="noopener">Original Project</a>
|
||||
| Maintained by <strong>@linuxiarz.pl</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
{% block page_js %}{% endblock %}
|
||||
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Auto-dismiss alerts after 5 seconds
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
|
||||
alerts.forEach(alert => {
|
||||
setTimeout(() => {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
115
templates/certificate_manager.html
Normal file
115
templates/certificate_manager.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}HAProxy • Certificate Manager{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
|
||||
<li class="breadcrumb-item active">SSL Certificates</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-shield-lock"></i> SSL Certificates</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Certificate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Certificates Table -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark">
|
||||
<h5 class="mb-0"><i class="bi bi-list-check"></i> Certificates List</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="certsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Common Name (CN)</th>
|
||||
<th>Expires</th>
|
||||
<th>Used By</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="certsList">
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">Upload SSL Certificate</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Certificate Name *</label>
|
||||
<input type="text" class="form-control" id="cert_name" required>
|
||||
<small class="text-muted">e.g., example-com-2025</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">PEM Certificate File * <i class="bi bi-info-circle" title="Combined certificate + key in PEM format"></i></label>
|
||||
<input type="file" class="form-control" id="cert_file" accept=".pem,.crt,.cert,.key" required>
|
||||
<small class="text-muted">
|
||||
Supported formats: PEM (combined cert+key), .crt, .cert, .key
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>Supported Formats:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>Single file with both certificate and private key (PEM)</li>
|
||||
<li>PKCS#12 (.p12) - will be converted</li>
|
||||
<li>Separate .crt and .key files - upload combined</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="uploadBtn">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Certificate Details Modal -->
|
||||
<div class="modal fade" id="detailsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-info text-white">
|
||||
<h5 class="modal-title">Certificate Details: <span id="detailsName"></span></h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="detailsContent"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-success" id="exportBtn">
|
||||
<i class="bi bi-download"></i> Export PEM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/cert_manager.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
191
templates/dashboard.html
Normal file
191
templates/dashboard.html
Normal file
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}HAProxy • Dashboard{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
|
||||
<li class="breadcrumb-item active">VHosts</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-server"></i> Virtual Hosts</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newVHostModal">
|
||||
<i class="bi bi-plus-lg"></i> Create VHost
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Total VHosts</h5>
|
||||
<h3 id="total_vhosts">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Enabled</h5>
|
||||
<h3 id="enabled_vhosts">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-warning text-dark">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Disabled</h5>
|
||||
<h3 id="disabled_vhosts">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-info text-white">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SSL Enabled</h5>
|
||||
<h3 id="ssl_vhosts">0</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VHosts Table -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark">
|
||||
<h5 class="mb-0"><i class="bi bi-list-check"></i> VHosts List</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="vhostsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Bind</th>
|
||||
<th>Protocol</th>
|
||||
<th>Servers</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vhostsList">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New VHost Modal -->
|
||||
<div class="modal fade" id="newVHostModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">Create New VHost</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="newVHostForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">VHost Name *</label>
|
||||
<input type="text" class="form-control" id="vhost_name" required>
|
||||
<small class="text-muted">e.g., web-app-01</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Hostname *</label>
|
||||
<input type="text" class="form-control" id="vhost_hostname" required>
|
||||
<small class="text-muted">e.g., example.com</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Frontend Port *</label>
|
||||
<input type="number" class="form-control" id="vhost_port" value="443" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Protocol</label>
|
||||
<select class="form-select" id="vhost_protocol">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Load Balancing Method</label>
|
||||
<select class="form-select" id="vhost_lb_method">
|
||||
<option value="roundrobin">Round Robin</option>
|
||||
<option value="leastconn">Least Connections</option>
|
||||
<option value="source">Source IP Hash</option>
|
||||
<option value="uri">URI Hash</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="vhost_ssl">
|
||||
<label class="form-check-label" for="vhost_ssl">
|
||||
Enable SSL
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h6>Backend Servers</h6>
|
||||
<div id="backendServersContainer">
|
||||
<div class="backend-server mb-3 p-3 border rounded">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="text" class="form-control backend-ip" placeholder="IP Address" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-2">
|
||||
<input type="number" class="form-control backend-port" placeholder="Port" value="80" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger remove-server">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="addServerBtn">
|
||||
<i class="bi bi-plus"></i> Add Server
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="createVHostBtn">Create VHost</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit VHost Modal -->
|
||||
<div class="modal fade" id="editVHostModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">Edit VHost: <span id="editVHostName"></span></h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editVHostForm">
|
||||
<div id="editFormContent"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="updateVHostBtn">Update VHost</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/vhost_manager.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
132
templates/login.html
Normal file
132
templates/login.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HAProxy Configurator - Login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
padding: 40px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.login-header i {
|
||||
font-size: 48px;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
.login-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-control {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.btn-login:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.alert {
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.default-creds {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.default-creds strong {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
<h1>HAProxy</h1>
|
||||
<p>Configurator & Manager</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-login">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="default-creds">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Default credentials:</strong><br>
|
||||
Username: <code>admin</code><br>
|
||||
Password: <code>admin123</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
115
templates/user_management.html
Normal file
115
templates/user_management.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set active_page = "users" %}
|
||||
|
||||
{% block title %}HAProxy • User Management{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
|
||||
<li class="breadcrumb-item active">Users</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-people me-2"></i>User Management</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- New User Button -->
|
||||
<button class="btn btn-success mb-3" data-bs-toggle="modal" data-bs-target="#newUserModal">
|
||||
<i class="bi bi-person-plus me-1"></i>Add New User
|
||||
</button>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="usersTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersList">
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New User Modal -->
|
||||
<div class="modal fade" id="newUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add New User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="newUserForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="newUsername" required minlength="3">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="newPassword" required minlength="6">
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="newIsAdmin">
|
||||
<label class="form-check-label" for="newIsAdmin">
|
||||
Admin privileges
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="createUserBtn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div class="modal fade" id="editUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit User: <span id="editUsername"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editUserForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password (leave blank to keep current)</label>
|
||||
<input type="password" class="form-control" id="editPassword" minlength="6">
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="editIsAdmin">
|
||||
<label class="form-check-label" for="editIsAdmin">
|
||||
Admin privileges
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="updateUserBtn">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/user_manager.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
123
utils/cert_manager.py
Normal file
123
utils/cert_manager.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Certificate Management Utilities - Parse, Validate, Save"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_certificate(pem_content):
|
||||
"""
|
||||
Parse PEM certificate and extract metadata
|
||||
Returns: dict with 'common_name', 'expires_at', 'subject_alt_names', 'error'
|
||||
"""
|
||||
try:
|
||||
# Split cert and key if combined
|
||||
cert_only = None
|
||||
key_only = None
|
||||
|
||||
# Extract certificate
|
||||
cert_match = re.search(
|
||||
r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
|
||||
pem_content,
|
||||
re.DOTALL
|
||||
)
|
||||
if not cert_match:
|
||||
return {'error': 'No valid certificate found in PEM file'}
|
||||
|
||||
cert_only = cert_match.group(0)
|
||||
|
||||
# Extract private key if present
|
||||
key_match = re.search(
|
||||
r'-----BEGIN (?:RSA )?PRIVATE KEY-----.*?-----END (?:RSA )?PRIVATE KEY-----',
|
||||
pem_content,
|
||||
re.DOTALL
|
||||
)
|
||||
if key_match:
|
||||
key_only = key_match.group(0)
|
||||
|
||||
# Parse certificate
|
||||
try:
|
||||
cert = x509.load_pem_x509_certificate(
|
||||
cert_only.encode(),
|
||||
default_backend()
|
||||
)
|
||||
except Exception as e:
|
||||
return {'error': f'Invalid certificate: {str(e)}'}
|
||||
|
||||
# Extract common name
|
||||
common_name = None
|
||||
try:
|
||||
common_name = cert.subject.get_attributes_for_oid(
|
||||
x509.oid.NameOID.COMMON_NAME
|
||||
)[0].value
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract Subject Alternative Names (SAN)
|
||||
subject_alt_names = []
|
||||
try:
|
||||
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
for name in san_ext.value:
|
||||
if isinstance(name, x509.DNSName):
|
||||
subject_alt_names.append(name.value)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract dates
|
||||
issued_at = cert.not_valid_before if cert.not_valid_before else None
|
||||
expires_at = cert.not_valid_after if cert.not_valid_after else None
|
||||
|
||||
result = {
|
||||
'common_name': common_name,
|
||||
'cert_only': cert_only,
|
||||
'key_only': key_only,
|
||||
'issued_at': issued_at,
|
||||
'expires_at': expires_at,
|
||||
'subject_alt_names': subject_alt_names
|
||||
}
|
||||
|
||||
logger.info(f"[CERT] Parsed certificate: CN={common_name}, expires={expires_at}", flush=True)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CERT] Error parsing certificate: {e}", flush=True)
|
||||
return {'error': f'Failed to parse certificate: {str(e)}'}
|
||||
|
||||
|
||||
def save_cert_file(cert_path, cert_content):
|
||||
"""Save certificate to disk"""
|
||||
try:
|
||||
# Create directory if not exists
|
||||
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
|
||||
|
||||
# Write file
|
||||
with open(cert_path, 'w') as f:
|
||||
f.write(cert_content)
|
||||
|
||||
# Set permissions (owner only can read)
|
||||
os.chmod(cert_path, 0o600)
|
||||
|
||||
logger.info(f"[CERT] Saved certificate file: {cert_path}", flush=True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CERT] Error saving certificate file: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def delete_cert_file(cert_path):
|
||||
"""Delete certificate file from disk"""
|
||||
try:
|
||||
if os.path.exists(cert_path):
|
||||
os.remove(cert_path)
|
||||
logger.info(f"[CERT] Deleted certificate file: {cert_path}", flush=True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CERT] Error deleting certificate file: {e}", flush=True)
|
||||
return False
|
||||
222
utils/config_generator.py
Normal file
222
utils/config_generator.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""HAProxy Config Generator - Build config from database"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from database import db
|
||||
from database.models import VirtualHost, Certificate
|
||||
from config.settings import HAPROXY_CONFIG_PATH, HAPROXY_BACKUP_DIR, HAPROXY_STATS_PORT # ✅ TUTAJ!
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_haproxy_config():
|
||||
"""Generate HAProxy config from database"""
|
||||
config_lines = []
|
||||
|
||||
# ===== GLOBAL SECTION =====
|
||||
config_lines.extend([
|
||||
"# HAProxy Configuration - Auto-generated by HAProxy Manager",
|
||||
f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"",
|
||||
"global",
|
||||
" log stdout local0",
|
||||
" log stdout local1 notice",
|
||||
" chroot /var/lib/haproxy",
|
||||
" stats socket /run/haproxy/admin.sock mode 660 level admin",
|
||||
" stats timeout 30s",
|
||||
" user haproxy",
|
||||
" group haproxy",
|
||||
" daemon",
|
||||
" maxconn 4096",
|
||||
" tune.ssl.default-dh-param 2048",
|
||||
"",
|
||||
])
|
||||
|
||||
# ===== DEFAULTS SECTION =====
|
||||
config_lines.extend([
|
||||
"defaults",
|
||||
" log global",
|
||||
" mode http",
|
||||
" option httplog",
|
||||
" option dontlognull",
|
||||
" option http-server-close",
|
||||
" option forwardfor except 127.0.0.0/8",
|
||||
" option redispatch",
|
||||
" retries 3",
|
||||
" timeout connect 5000",
|
||||
" timeout client 50000",
|
||||
" timeout server 50000",
|
||||
" errorfile 400 /etc/haproxy/errors/400.http",
|
||||
" errorfile 403 /etc/haproxy/errors/403.http",
|
||||
" errorfile 408 /etc/haproxy/errors/408.http",
|
||||
" errorfile 500 /etc/haproxy/errors/500.http",
|
||||
" errorfile 502 /etc/haproxy/errors/502.http",
|
||||
" errorfile 503 /etc/haproxy/errors/503.http",
|
||||
" errorfile 504 /etc/haproxy/errors/504.http",
|
||||
"",
|
||||
])
|
||||
|
||||
# ===== STATS FRONTEND (HARDCODED :8404) =====
|
||||
config_lines.extend([
|
||||
"# HAProxy Statistics - Hardcoded on port 8404",
|
||||
"frontend stats",
|
||||
f" bind *:{HAPROXY_STATS_PORT}",
|
||||
" stats enable",
|
||||
" stats admin if TRUE",
|
||||
" stats uri /stats",
|
||||
" stats refresh 30s",
|
||||
"",
|
||||
])
|
||||
|
||||
# ===== FRONTENDS & BACKENDS FROM DATABASE =====
|
||||
vhosts = VirtualHost.query.filter_by(enabled=True).all()
|
||||
|
||||
for vhost in vhosts:
|
||||
# Skip if no backend servers
|
||||
if not vhost.backend_servers:
|
||||
logger.warning(f"[CONFIG] VHost '{vhost.name}' has no backend servers, skipping")
|
||||
continue
|
||||
|
||||
# ===== FRONTEND =====
|
||||
config_lines.append(f"# VHost: {vhost.name}")
|
||||
config_lines.append(f"frontend {vhost.name}")
|
||||
config_lines.append(f" description {vhost.hostname}")
|
||||
config_lines.append(f" bind {vhost.frontend_ip}:{vhost.frontend_port}")
|
||||
|
||||
# SSL config
|
||||
if vhost.use_ssl and vhost.certificate_id:
|
||||
cert = Certificate.query.get(vhost.certificate_id)
|
||||
if cert:
|
||||
# Certificate file path
|
||||
cert_path = f"/app/uploads/certificates/{cert.name}.pem"
|
||||
config_lines.append(f" ssl crt {cert_path}")
|
||||
|
||||
config_lines.append(f" mode {vhost.protocol}")
|
||||
config_lines.append(f" option httplog")
|
||||
|
||||
# Custom headers
|
||||
if vhost.del_server_header:
|
||||
config_lines.append(" http-response del-header Server")
|
||||
|
||||
if vhost.add_custom_header and vhost.custom_header_name:
|
||||
config_lines.append(f" http-response add-header {vhost.custom_header_name} {vhost.custom_header_value}")
|
||||
|
||||
if vhost.forward_for:
|
||||
config_lines.append(" option forwardfor except 127.0.0.0/8")
|
||||
|
||||
# Security rules
|
||||
if vhost.dos_protection:
|
||||
config_lines.extend([
|
||||
f" stick-table type ip size 100k expire {vhost.dos_ban_duration} store http_req_rate(10s)",
|
||||
f" http-request track-sc0 src",
|
||||
f" http-request deny if {{ sc_http_req_rate(0) gt {vhost.dos_limit_requests} }}",
|
||||
])
|
||||
|
||||
if vhost.sql_injection_check:
|
||||
config_lines.append(" # SQL Injection protection enabled")
|
||||
|
||||
if vhost.xss_check:
|
||||
config_lines.append(" # XSS protection enabled")
|
||||
|
||||
if vhost.webshell_check:
|
||||
config_lines.append(" # WebShell protection enabled")
|
||||
|
||||
config_lines.append(f" default_backend {vhost.name}_backend")
|
||||
config_lines.append("")
|
||||
|
||||
# ===== BACKEND =====
|
||||
config_lines.append(f"backend {vhost.name}_backend")
|
||||
config_lines.append(f" balance {vhost.lb_method}")
|
||||
config_lines.append(f" option httpchk GET / HTTP/1.1\\r\\nHost:\\ www")
|
||||
|
||||
# Backend servers
|
||||
for i, server in enumerate(vhost.backend_servers, 1):
|
||||
if not server.enabled:
|
||||
continue
|
||||
|
||||
server_line = f" server {server.name} {server.ip_address}:{server.port}"
|
||||
|
||||
if server.weight != 1:
|
||||
server_line += f" weight {server.weight}"
|
||||
|
||||
if server.maxconn:
|
||||
server_line += f" maxconn {server.maxconn}"
|
||||
|
||||
if server.health_check:
|
||||
server_line += f" check inter 5000 rise 2 fall 3"
|
||||
|
||||
config_lines.append(server_line)
|
||||
|
||||
config_lines.append("")
|
||||
|
||||
return "\n".join(config_lines)
|
||||
|
||||
|
||||
def save_haproxy_config(config_content):
|
||||
"""Save config to file"""
|
||||
try:
|
||||
# Create backup
|
||||
if os.path.exists(HAPROXY_CONFIG_PATH):
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = os.path.join(HAPROXY_BACKUP_DIR, f'haproxy_backup_{timestamp}.cfg')
|
||||
shutil.copy2(HAPROXY_CONFIG_PATH, backup_path)
|
||||
logger.info(f"[CONFIG] Backup created: {backup_path}", flush=True)
|
||||
|
||||
# Write new config
|
||||
with open(HAPROXY_CONFIG_PATH, 'w') as f:
|
||||
f.write(config_content)
|
||||
|
||||
logger.info(f"[CONFIG] Config saved to {HAPROXY_CONFIG_PATH}", flush=True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CONFIG] Error saving config: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def reload_haproxy():
|
||||
"""Reload HAProxy service"""
|
||||
try:
|
||||
# Generate config
|
||||
config_content = generate_haproxy_config()
|
||||
|
||||
# Test config syntax
|
||||
result = subprocess.run(
|
||||
['haproxy', '-c', '-f', HAPROXY_CONFIG_PATH],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[CONFIG] HAProxy syntax error: {result.stderr}", flush=True)
|
||||
return False
|
||||
|
||||
# Save config
|
||||
if not save_haproxy_config(config_content):
|
||||
return False
|
||||
|
||||
# Reload HAProxy
|
||||
result = subprocess.run(
|
||||
['systemctl', 'reload', 'haproxy'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("[CONFIG] HAProxy reloaded successfully", flush=True)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[CONFIG] HAProxy reload failed: {result.stderr}", flush=True)
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[CONFIG] HAProxy reload timeout", flush=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[CONFIG] Error reloading HAProxy: {e}", flush=True)
|
||||
return False
|
||||
Reference in New Issue
Block a user