Compare commits
62 Commits
5591772e76
...
allin_sqli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc28155fe | ||
|
|
dd31c1bdd0 | ||
|
|
762e51f886 | ||
|
|
84759f9508 | ||
|
|
5a687549a9 | ||
|
|
577dc789fc | ||
|
|
398ccce3b5 | ||
|
|
411291c8b9 | ||
|
|
bb3aa9f179 | ||
|
|
f8a05554c1 | ||
|
|
f5cfc5bb33 | ||
|
|
06fce272c1 | ||
|
|
75e3718e70 | ||
|
|
899e698353 | ||
|
|
03b7f20b8c | ||
|
|
1c6ecb9230 | ||
|
|
b85efadd87 | ||
|
|
0c321859b9 | ||
|
|
addb21bc3e | ||
| 32ef62e4ac | |||
|
|
04acb4ac21 | ||
|
|
9949e34d68 | ||
|
|
0a027bbebd | ||
|
|
3e7861f489 | ||
|
|
da1af612ef | ||
|
|
370c7099f5 | ||
|
|
27f9984574 | ||
|
|
34c84f1115 | ||
| 71b0b39a0f | |||
|
|
bdc9231ea3 | ||
|
|
3763a60adf | ||
|
|
c349c8e77a | ||
|
|
087d2a46c3 | ||
|
|
f2c9f166f6 | ||
|
|
c857258dc6 | ||
|
|
2d53843f34 | ||
|
|
c7a09171e1 | ||
|
|
86de6f24bd | ||
|
|
7b49105ba3 | ||
|
|
f082495a13 | ||
|
|
ca39dd35ae | ||
|
|
84d7139c15 | ||
|
|
8254290049 | ||
|
|
ae280b1062 | ||
|
|
df1355ec2d | ||
|
|
4539f03f9a | ||
|
|
82f335020b | ||
|
|
45d8634f08 | ||
|
|
c838521adc | ||
|
|
9fae35fe8a | ||
|
|
58205be555 | ||
|
|
d01ca3512e | ||
|
|
014dc76ff6 | ||
|
|
df70118653 | ||
|
|
acef7eb610 | ||
|
|
7a33291342 | ||
|
|
72bf6eb9d1 | ||
|
|
e4a3671f90 | ||
|
|
8683af493f | ||
|
|
80a0d22d4e | ||
|
|
f96b426788 | ||
|
|
b305368690 |
@@ -1,9 +1,28 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.env
|
||||||
*.log
|
.env.local
|
||||||
.pytest_cache
|
|
||||||
venv/
|
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.log
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.DS_Store
|
||||||
|
*.db-journal
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.temp
|
||||||
|
instance/app.db
|
||||||
|
uploads/
|
||||||
|
backups/
|
||||||
|
logs/
|
||||||
|
|||||||
19
.env.example
Normal file
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
|
venv
|
||||||
logs/*
|
logs/*
|
||||||
__pycache__
|
__pycache__
|
||||||
config/*
|
config/auth/*
|
||||||
71
Dockerfile
71
Dockerfile
@@ -1,49 +1,80 @@
|
|||||||
FROM python:3.14-rc-trixie
|
FROM python:3.14-rc-trixie
|
||||||
|
|
||||||
|
# ===== ENV VARIABLES =====
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
# Install dependencies
|
LABEL maintainer="HAProxy Manager" \
|
||||||
RUN apt-get update && apt-get install -y \
|
version="2.0" \
|
||||||
|
description="HAProxy Configuration Manager with SQLAlchemy"
|
||||||
|
|
||||||
|
# ===== INSTALL SYSTEM DEPENDENCIES =====
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
haproxy \
|
haproxy \
|
||||||
supervisor \
|
|
||||||
openssl \
|
openssl \
|
||||||
|
ca-certificates \
|
||||||
|
supervisor \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
wget \
|
||||||
|
git \
|
||||||
|
vim \
|
||||||
|
libssl-dev \
|
||||||
|
libffi-dev \
|
||||||
|
python3-dev \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
# ===== WORKDIR =====
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy requirements and install
|
# ===== COPY & INSTALL PYTHON REQUIREMENTS =====
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --upgrade pip setuptools wheel && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application
|
# ===== COPY APPLICATION FILES =====
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY log_parser.py .
|
COPY log_parser.py .
|
||||||
|
|
||||||
|
# Copy directory structure
|
||||||
|
COPY config/ config/
|
||||||
|
COPY database/ database/
|
||||||
COPY routes/ routes/
|
COPY routes/ routes/
|
||||||
COPY utils/ utils/
|
COPY utils/ utils/
|
||||||
COPY auth/ auth/
|
COPY auth/ auth/
|
||||||
COPY templates/ templates/
|
COPY templates/ templates/
|
||||||
COPY static/ /app/static/
|
COPY static/ static/
|
||||||
|
|
||||||
|
# ===== CREATE REQUIRED DIRECTORIES =====
|
||||||
# Create directories
|
RUN mkdir -p /app/instance \
|
||||||
RUN mkdir -p /app/config/auth \
|
&& mkdir -p /app/uploads/certificates \
|
||||||
&& mkdir -p /app/config/ssl \
|
&& mkdir -p /app/backups \
|
||||||
|
&& mkdir -p /app/logs \
|
||||||
|
&& mkdir -p /etc/haproxy \
|
||||||
&& mkdir -p /etc/supervisor/conf.d \
|
&& mkdir -p /etc/supervisor/conf.d \
|
||||||
&& mkdir -p /var/log/supervisor \
|
&& mkdir -p /var/log/supervisor \
|
||||||
&& mkdir -p /etc/haproxy
|
&& mkdir -p /var/run/haproxy \
|
||||||
|
&& touch /etc/haproxy/haproxy.cfg
|
||||||
|
|
||||||
# Copy configs
|
# ===== COPY CONFIGS =====
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /entrypoint.sh
|
# ===== SET PERMISSIONS (POPRAWIONE - && ВЕЗДЕ!) =====
|
||||||
|
RUN chmod +x /entrypoint.sh && \
|
||||||
# Health check
|
chmod 755 /app && \
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
chmod -R 755 /app/uploads && \
|
||||||
CMD curl -f https://localhost:5000 --insecure 2>/dev/null || exit 1
|
chmod -R 755 /app/backups && \
|
||||||
|
chmod -R 755 /app/logs
|
||||||
|
|
||||||
|
# ===== EXPOSE PORTS =====
|
||||||
EXPOSE 5000 80 443 8404
|
EXPOSE 5000 80 443 8404
|
||||||
|
|
||||||
|
# ===== HEALTHCHECK =====
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:5000/api/current-user 2>/dev/null || exit 1
|
||||||
|
|
||||||
|
# ===== ENTRYPOINT =====
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
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!)
|
||||||
|
|||||||
295
app.py
295
app.py
@@ -1,121 +1,252 @@
|
|||||||
|
"""
|
||||||
|
HAProxy Configurator - Main Application
|
||||||
|
SQLAlchemy + Flask-SQLAlchemy Integration
|
||||||
|
"""
|
||||||
|
|
||||||
from flask import Flask, render_template, render_template_string
|
|
||||||
import configparser
|
|
||||||
import ssl
|
|
||||||
from routes.main_routes import main_bp
|
|
||||||
from routes.edit_routes import edit_bp
|
|
||||||
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
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends
|
import ssl
|
||||||
|
import configparser
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ===== BASE DIRECTORY =====
|
||||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CREATE REQUIRED DIRECTORIES =====
|
||||||
|
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
|
||||||
|
os.makedirs(INSTANCE_DIR, exist_ok=True)
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
os.makedirs(HAPROXY_BACKUP_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"[APP] Base directory: {BASE_DIR}", flush=True)
|
||||||
|
print(f"[APP] Instance directory: {INSTANCE_DIR}", flush=True)
|
||||||
|
print(f"[APP] Database: {SQLALCHEMY_DATABASE_URI}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CREATE FLASK APP =====
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
static_folder=os.path.join(BASE_DIR, 'static'),
|
static_folder=os.path.join(BASE_DIR, 'static'),
|
||||||
static_url_path='/static',
|
static_url_path='/static',
|
||||||
template_folder=os.path.join(BASE_DIR, 'templates')
|
template_folder=os.path.join(BASE_DIR, 'templates'),
|
||||||
|
instance_path=INSTANCE_DIR
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG_DIR_DOCKER = '/etc/haproxy-configurator'
|
|
||||||
CONFIG_DIR_LOCAL = './config'
|
|
||||||
CONFIG_DIR_ENV = os.environ.get('CONFIG_DIR', None)
|
|
||||||
|
|
||||||
if CONFIG_DIR_ENV and os.path.exists(CONFIG_DIR_ENV):
|
# ===== LOAD CONFIGURATION =====
|
||||||
CONFIG_DIR = CONFIG_DIR_ENV
|
app.config.from_object('config.settings')
|
||||||
elif os.path.exists(CONFIG_DIR_DOCKER):
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||||
CONFIG_DIR = CONFIG_DIR_DOCKER
|
app.config['SESSION_COOKIE_SECURE'] = False
|
||||||
elif os.path.exists(CONFIG_DIR_LOCAL):
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
CONFIG_DIR = CONFIG_DIR_LOCAL
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
else:
|
|
||||||
CONFIG_DIR = CONFIG_DIR_DOCKER
|
|
||||||
|
|
||||||
AUTH_CFG = os.path.join(CONFIG_DIR, 'auth', 'auth.cfg')
|
|
||||||
SSL_INI = os.path.join(CONFIG_DIR, 'ssl.ini')
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(AUTH_CFG), exist_ok=True)
|
# ===== INITIALIZE DATABASE =====
|
||||||
os.makedirs(os.path.dirname(SSL_INI), exist_ok=True)
|
print("[APP] Initializing database...", flush=True)
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
print("[APP] Database initialized", flush=True)
|
||||||
|
|
||||||
BASIC_AUTH_USERNAME = "admin"
|
|
||||||
BASIC_AUTH_PASSWORD = "admin"
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth_config = configparser.ConfigParser()
|
|
||||||
auth_config.read(AUTH_CFG)
|
|
||||||
if auth_config.has_section('auth'):
|
|
||||||
BASIC_AUTH_USERNAME = auth_config.get('auth', 'username', fallback='admin')
|
|
||||||
BASIC_AUTH_PASSWORD = auth_config.get('auth', 'password', fallback='admin')
|
|
||||||
else:
|
|
||||||
BASIC_AUTH_USERNAME = "admin"
|
|
||||||
BASIC_AUTH_PASSWORD = "admin"
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[APP] Auth config error: {e}, using defaults", flush=True)
|
|
||||||
BASIC_AUTH_USERNAME = "admin"
|
|
||||||
BASIC_AUTH_PASSWORD = "admin"
|
|
||||||
|
|
||||||
|
# ===== REGISTER BLUEPRINTS =====
|
||||||
|
print("[APP] Registering blueprints...", flush=True)
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(edit_bp)
|
app.register_blueprint(edit_bp)
|
||||||
|
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)
|
setup_auth(app)
|
||||||
|
print("[APP] Auth middleware setup complete", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SSL CONTEXT SETUP =====
|
||||||
certificate_path = None
|
certificate_path = None
|
||||||
private_key_path = None
|
private_key_path = None
|
||||||
ssl_context = None
|
ssl_context = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config2 = configparser.ConfigParser()
|
if os.path.exists(SSL_INI):
|
||||||
config2.read(SSL_INI)
|
config_ssl = configparser.ConfigParser()
|
||||||
|
config_ssl.read(SSL_INI)
|
||||||
if config2.has_section('ssl'):
|
|
||||||
certificate_path = config2.get('ssl', 'certificate_path')
|
if config_ssl.has_section('ssl'):
|
||||||
private_key_path = config2.get('ssl', 'private_key_path')
|
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:
|
else:
|
||||||
print(f"[APP] ✗ No [ssl] section in {SSL_INI}", flush=True)
|
print(f"[APP] No SSL config file found: {SSL_INI}", flush=True)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not os.path.exists(certificate_path):
|
|
||||||
print(f"[APP] ✗ Certificate not found: {certificate_path}", flush=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not os.path.exists(private_key_path):
|
|
||||||
print(f"[APP] ✗ Private key not found: {private_key_path}", flush=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
|
|
||||||
ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path)
|
|
||||||
print(f"[APP] ✓ SSL context loaded", flush=True)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[APP] ✗ SSL error: {e}", flush=True)
|
print(f"[APP] SSL warning (non-critical): {e}", flush=True)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
# ===== ROUTES =====
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Index/Home - Redirect to login or dashboard"""
|
||||||
|
if 'user_id' not in session:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/statistics')
|
@app.route('/statistics')
|
||||||
|
@login_required
|
||||||
def display_haproxy_stats():
|
def display_haproxy_stats():
|
||||||
haproxy_stats = fetch_haproxy_stats()
|
"""Display HAProxy statistics"""
|
||||||
parsed_stats = parse_haproxy_stats(haproxy_stats)
|
try:
|
||||||
return render_template('statistics.html', stats=parsed_stats)
|
haproxy_stats = fetch_haproxy_stats()
|
||||||
|
parsed_stats = parse_haproxy_stats(haproxy_stats)
|
||||||
@app.route('/logs')
|
return render_template('statistics.html', stats=parsed_stats)
|
||||||
def display_logs():
|
except Exception as e:
|
||||||
log_file_path = '/var/log/haproxy.log'
|
print(f"[STATS] Error: {e}", flush=True)
|
||||||
parsed_entries = parse_log_file(log_file_path)
|
return render_template('statistics.html', stats={}, error=str(e))
|
||||||
return render_template('logs.html', entries=parsed_entries)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/home')
|
@app.route('/dashboard')
|
||||||
def home():
|
@login_required
|
||||||
frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends()
|
def dashboard():
|
||||||
return render_template('home.html',
|
"""Dashboard - Overview of vhosts and status"""
|
||||||
frontend_count=frontend_count,
|
from database.models import VirtualHost
|
||||||
backend_count=backend_count,
|
try:
|
||||||
acl_count=acl_count,
|
vhosts = VirtualHost.query.all()
|
||||||
layer7_count=layer7_count,
|
return render_template('dashboard.html', vhosts=vhosts)
|
||||||
layer4_count=layer4_count)
|
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: ")
|
||||||
|
|
||||||
|
from database.models import User
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
print(f"Error: User '{username}' already exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = User(username=username, is_admin=True)
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(f"[CLI] Admin user '{username}' created successfully")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== MAIN ENTRY POINT =====
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='::', port=5000, ssl_context=ssl_context, debug=True)
|
print("""
|
||||||
|
╔════════════════════════════════════════╗
|
||||||
|
║ HAProxy Configurator & Manager ║
|
||||||
|
║ Starting Application... ║
|
||||||
|
╚════════════════════════════════════════╝
|
||||||
|
""", flush=True)
|
||||||
|
|
||||||
|
print(f"[APP] Environment: {'Development' if DEBUG else 'Production'}", flush=True)
|
||||||
|
print(f"[APP] Running on: https://[::]:5000 (IPv6)", flush=True)
|
||||||
|
|
||||||
|
# Initialize database before running
|
||||||
|
init_db(app)
|
||||||
|
|
||||||
|
app.run(
|
||||||
|
host='::',
|
||||||
|
port=5000,
|
||||||
|
ssl_context=None,
|
||||||
|
debug=DEBUG,
|
||||||
|
use_reloader=DEBUG
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,53 +1,39 @@
|
|||||||
import os
|
"""Auth middleware - Updated for database"""
|
||||||
import functools
|
|
||||||
from flask import request, Response
|
|
||||||
import configparser
|
|
||||||
|
|
||||||
# Docker paths
|
from functools import wraps
|
||||||
CONFIG_DIR = './config'
|
from flask import session, redirect, url_for
|
||||||
AUTH_CFG = os.path.join(CONFIG_DIR, 'auth', 'auth.cfg')
|
from database.models import User
|
||||||
|
|
||||||
# Ensure config directory exists
|
|
||||||
os.makedirs(os.path.dirname(AUTH_CFG), exist_ok=True)
|
|
||||||
|
|
||||||
# Load auth credentials with fallback defaults
|
def login_required(f):
|
||||||
BASIC_AUTH_USERNAME = "admin"
|
"""Require login for view"""
|
||||||
BASIC_AUTH_PASSWORD = "admin"
|
@wraps(f)
|
||||||
|
|
||||||
try:
|
|
||||||
if os.path.exists(AUTH_CFG):
|
|
||||||
auth_config = configparser.ConfigParser()
|
|
||||||
auth_config.read(AUTH_CFG)
|
|
||||||
if auth_config.has_section('auth'):
|
|
||||||
BASIC_AUTH_USERNAME = auth_config.get('auth', 'username', fallback='admin')
|
|
||||||
BASIC_AUTH_PASSWORD = auth_config.get('auth', 'password', fallback='admin')
|
|
||||||
print(f"[AUTH] Loaded credentials from {AUTH_CFG}", flush=True)
|
|
||||||
else:
|
|
||||||
print(f"[AUTH] No [auth] section in {AUTH_CFG}, using defaults", flush=True)
|
|
||||||
else:
|
|
||||||
print(f"[AUTH] {AUTH_CFG} not found, using defaults", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[AUTH] Error loading config: {e}, using defaults", flush=True)
|
|
||||||
|
|
||||||
def check_auth(username, password):
|
|
||||||
return username == BASIC_AUTH_USERNAME and password == BASIC_AUTH_PASSWORD
|
|
||||||
|
|
||||||
def authenticate():
|
|
||||||
return Response(
|
|
||||||
'Could not verify your access level for that URL.\n'
|
|
||||||
'You have to login with proper credentials',
|
|
||||||
401,
|
|
||||||
{'WWW-Authenticate': 'Basic realm="Login Required"'}
|
|
||||||
)
|
|
||||||
|
|
||||||
def requires_auth(f):
|
|
||||||
@functools.wraps(f)
|
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
auth = request.authorization
|
if 'user_id' not in session:
|
||||||
if not auth or not check_auth(auth.username, auth.password):
|
return redirect(url_for('auth.login', next=request.url))
|
||||||
return authenticate()
|
|
||||||
|
# Verify user still exists
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if not user:
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
def setup_auth(app):
|
def setup_auth(app):
|
||||||
pass
|
"""Setup auth for Flask app"""
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
"""Before each request - update session user info"""
|
||||||
|
if 'user_id' in session:
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if user:
|
||||||
|
# Sync session data
|
||||||
|
session['username'] = user.username
|
||||||
|
session['is_admin'] = user.is_admin
|
||||||
|
else:
|
||||||
|
# User was deleted
|
||||||
|
session.clear()
|
||||||
|
|||||||
1
config/__init__.py
Normal file
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:
|
services:
|
||||||
haproxy-configurator:
|
haproxy-configurator-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: haproxy-configurator
|
container_name: haproxy-configurator-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ===== PORTS =====
|
||||||
ports:
|
ports:
|
||||||
- "15000:5000"
|
- "15001:5000" # Flask app (manager UI)
|
||||||
- "80:80"
|
- "81:80" # HAProxy HTTP
|
||||||
- "443:443"
|
- "444:443" # HAProxy HTTPS
|
||||||
- "8404:8404"
|
- "8405:8404" # HAProxy Stats (hardcoded 8404 inside)
|
||||||
|
|
||||||
|
# ===== VOLUMES =====
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
# Application data
|
||||||
- ./haproxy:/etc/haproxy
|
- ./instance:/app/instance # SQLite database
|
||||||
- ./logs:/var/log
|
- ./uploads/certificates:/app/uploads/certificates # SSL certificates
|
||||||
- ./ssl:/app/ssl
|
- ./backups:/app/backups # Config backups
|
||||||
|
|
||||||
|
# HAProxy config
|
||||||
|
- ./haproxy:/etc/haproxy # HAProxy config directory
|
||||||
|
- ./logs/haproxy:/var/log/haproxy # HAProxy logs
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
- ./logs/app:/app/logs # Application logs
|
||||||
|
- ./logs/supervisor:/var/log/supervisor # Supervisor logs
|
||||||
|
|
||||||
|
# ===== ENVIRONMENT =====
|
||||||
environment:
|
environment:
|
||||||
|
# Flask
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
|
- FLASK_DEBUG=0
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
- DATABASE_URL=sqlite:////app/instance/app.db
|
||||||
|
|
||||||
|
# Admin credentials (initial)
|
||||||
|
- ADMIN_USERNAME=admin
|
||||||
|
- ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Secret key (CHANGE IN PRODUCTION!)
|
||||||
|
- SECRET_KEY=change-me-in-production-$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
# HAProxy
|
||||||
|
- HAPROXY_CONFIG_PATH=/etc/haproxy/haproxy.cfg
|
||||||
|
- HAPROXY_STATS_PORT=8404
|
||||||
|
|
||||||
|
# ===== CAPABILITIES =====
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_ADMIN
|
- SYS_ADMIN
|
||||||
|
- DAC_OVERRIDE
|
||||||
|
|
||||||
|
# ===== LOGGING =====
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
labels: "service=haproxy-configurator"
|
||||||
|
|
||||||
networks: [intranet]
|
# ===== HEALTHCHECK =====
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/api/current-user", "--fail"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# ===== NETWORK =====
|
||||||
|
networks:
|
||||||
|
- intranet
|
||||||
|
|
||||||
|
# ===== DEPENDENCIES =====
|
||||||
|
depends_on:
|
||||||
|
- haproxy-configurator-init
|
||||||
|
|
||||||
|
# ===== INIT SERVICE (tworzy tabele i strukturę) =====
|
||||||
|
haproxy-configurator-init:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: haproxy-configurator-init
|
||||||
|
entrypoint: >
|
||||||
|
sh -c "
|
||||||
|
echo '[INIT] Initializing database...';
|
||||||
|
python -c 'from app import app; from database import init_db; init_db(app)';
|
||||||
|
echo '[INIT] Database initialized!';
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
volumes:
|
||||||
|
- ./instance:/app/instance
|
||||||
|
- ./uploads/certificates:/app/uploads/certificates
|
||||||
|
- ./backups:/app/backups
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
networks:
|
||||||
|
- intranet
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
intranet:
|
intranet:
|
||||||
external: true
|
driver: bridge
|
||||||
|
# uncomment dla external network:
|
||||||
|
# external: true
|
||||||
|
|||||||
102
entrypoint.sh
102
entrypoint.sh
@@ -1,77 +1,41 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "[$(date)] Starting HAProxy Configurator..."
|
echo "╔════════════════════════════════════════╗"
|
||||||
|
echo "║ HAProxy Manager - Entrypoint ║"
|
||||||
|
echo "║ Starting services... ║"
|
||||||
|
echo "╚════════════════════════════════════════╝"
|
||||||
|
|
||||||
# Create directories if they don't exist
|
# ===== CHECK ENVIRONMENT =====
|
||||||
mkdir -p /app/config/auth
|
echo "[STARTUP] Environment: ${FLASK_ENV:-production}"
|
||||||
mkdir -p /app/config/ssl
|
echo "[STARTUP] Python version: $(python --version)"
|
||||||
mkdir -p /etc/haproxy
|
|
||||||
mkdir -p /var/log/supervisor
|
|
||||||
|
|
||||||
# Create default auth.cfg if doesn't exist
|
# ===== INIT DATABASE =====
|
||||||
if [ ! -f /app/config/auth/auth.cfg ]; then
|
echo "[STARTUP] Initializing database..."
|
||||||
cat > /app/config/auth/auth.cfg <<EOF
|
python -c "
|
||||||
[auth]
|
from app import app
|
||||||
username = admin
|
from database import init_db
|
||||||
password = admin123
|
with app.app_context():
|
||||||
EOF
|
init_db(app)
|
||||||
echo "[$(date)] Created default auth.cfg"
|
print('[STARTUP] Database initialized successfully')
|
||||||
fi
|
" || {
|
||||||
|
echo "[ERROR] Database initialization failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Create default ssl.ini if doesn't exist
|
# ===== GENERATE INITIAL HAPROXY CONFIG =====
|
||||||
if [ ! -f /app/config/ssl.ini ]; then
|
echo "[STARTUP] Generating initial HAProxy config..."
|
||||||
cat > /app/config/ssl.ini <<EOF
|
python -c "
|
||||||
[ssl]
|
from app import app
|
||||||
certificate_path = /app/config/ssl/haproxy-configurator.pem
|
from utils.config_generator import generate_haproxy_config, save_haproxy_config
|
||||||
private_key_path = /app/config/ssl/haproxy-configurator.pem
|
with app.app_context():
|
||||||
EOF
|
config = generate_haproxy_config()
|
||||||
echo "[$(date)] Created default ssl.ini"
|
save_haproxy_config(config)
|
||||||
fi
|
print('[STARTUP] HAProxy config generated')
|
||||||
|
" || {
|
||||||
|
echo "[WARNING] Could not generate initial config, continuing..."
|
||||||
|
}
|
||||||
|
|
||||||
# Generate self-signed certificate if doesn't exist
|
# ===== START SUPERVISOR (runs Flask + HAProxy + other services) =====
|
||||||
if [ ! -f /app/config/ssl/haproxy-configurator.pem ]; then
|
echo "[STARTUP] Starting supervisord..."
|
||||||
openssl req -x509 -newkey rsa:2048 -keyout /app/config/ssl/haproxy-configurator.pem \
|
|
||||||
-out /app/config/ssl/haproxy-configurator.pem -days 365 -nodes \
|
|
||||||
-subj "/C=PL/ST=State/L=City/O=Organization/CN=haproxy-configurator.local"
|
|
||||||
chmod 600 /app/config/ssl/haproxy-configurator.pem
|
|
||||||
echo "[$(date)] Generated SSL certificate"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create default haproxy.cfg if doesn't exist or is empty
|
|
||||||
if [ ! -s /etc/haproxy/haproxy.cfg ]; then
|
|
||||||
cat > /etc/haproxy/haproxy.cfg <<'HAPROXYCFG'
|
|
||||||
global
|
|
||||||
log stdout local0
|
|
||||||
maxconn 4096
|
|
||||||
|
|
||||||
defaults
|
|
||||||
log global
|
|
||||||
mode http
|
|
||||||
option httplog
|
|
||||||
option dontlognull
|
|
||||||
timeout connect 5000
|
|
||||||
timeout client 50000
|
|
||||||
timeout server 50000
|
|
||||||
|
|
||||||
listen stats
|
|
||||||
bind *:8404
|
|
||||||
stats enable
|
|
||||||
stats uri /stats
|
|
||||||
stats refresh 30s
|
|
||||||
stats show-legends
|
|
||||||
HAPROXYCFG
|
|
||||||
echo "[$(date)] Created default haproxy.cfg"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set proper permissions
|
|
||||||
chmod 600 /app/config/ssl/haproxy-configurator.pem 2>/dev/null || true
|
|
||||||
chmod 644 /app/config/auth/auth.cfg
|
|
||||||
chmod 644 /app/config/ssl.ini
|
|
||||||
chmod 644 /etc/haproxy/haproxy.cfg
|
|
||||||
|
|
||||||
echo "[$(date)] Configuration ready"
|
|
||||||
echo "[$(date)] Starting supervisord..."
|
|
||||||
|
|
||||||
# Start supervisord
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|||||||
216
log_parser.py
216
log_parser.py
@@ -1,7 +1,14 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
def parse_log_file(log_file_path):
|
def parse_log_file(log_file_path):
|
||||||
|
"""
|
||||||
|
Parse HAProxy syslog format and identify security threats.
|
||||||
|
Format: <134>Nov 3 09:18:35 haproxy[18]: IP:PORT [DATE:TIME] FRONTEND BACKEND STATUS BYTES ...
|
||||||
|
"""
|
||||||
parsed_entries = []
|
parsed_entries = []
|
||||||
|
|
||||||
|
# Security threat patterns
|
||||||
xss_patterns = [
|
xss_patterns = [
|
||||||
r'<\s*script\s*',
|
r'<\s*script\s*',
|
||||||
r'javascript:',
|
r'javascript:',
|
||||||
@@ -12,92 +19,147 @@ def parse_log_file(log_file_path):
|
|||||||
r'<\s*input\s*[^>]*\s*value\s*=?',
|
r'<\s*input\s*[^>]*\s*value\s*=?',
|
||||||
r'<\s*form\s*action\s*=?',
|
r'<\s*form\s*action\s*=?',
|
||||||
r'<\s*svg\s*on\w+\s*=?',
|
r'<\s*svg\s*on\w+\s*=?',
|
||||||
r'script',
|
r'alert\s*\(',
|
||||||
r'alert',
|
|
||||||
r'onerror',
|
r'onerror',
|
||||||
r'onload',
|
r'onload',
|
||||||
r'javascript'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
sql_patterns = [
|
sql_patterns = [
|
||||||
r';',
|
r'(union|select|insert|update|delete|drop)\s+(from|into|table)',
|
||||||
r'substring',
|
r';\s*(union|select|insert|update|delete|drop)',
|
||||||
r'extract',
|
r'substring\s*\(',
|
||||||
r'union\s+all',
|
r'extract\s*\(',
|
||||||
r'order\s+by',
|
r'order\s+by\s+\d+',
|
||||||
r'--\+',
|
r'--\+',
|
||||||
r'union',
|
r'1\s*=\s*1',
|
||||||
r'select',
|
r'@@\w+',
|
||||||
r'insert',
|
|
||||||
r'update',
|
|
||||||
r'delete',
|
|
||||||
r'drop',
|
|
||||||
r'@@',
|
|
||||||
r'1=1',
|
|
||||||
r'`1',
|
r'`1',
|
||||||
r'union',
|
|
||||||
r'select',
|
|
||||||
r'insert',
|
|
||||||
r'update',
|
|
||||||
r'delete',
|
|
||||||
r'drop',
|
|
||||||
r'@@',
|
|
||||||
r'1=1',
|
|
||||||
r'`1'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
webshells_patterns = [
|
webshells_patterns = [
|
||||||
r'payload',
|
r'eval\s*\(',
|
||||||
r'eval|system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec|cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil.*\.php.*'
|
r'system\s*\(',
|
||||||
|
r'passthru\s*\(',
|
||||||
|
r'shell_exec\s*\(',
|
||||||
|
r'exec\s*\(',
|
||||||
|
r'popen\s*\(',
|
||||||
|
r'proc_open\s*\(',
|
||||||
|
r'backdoor|webshell|phpspy|c99|kacak|b374k|wsos',
|
||||||
]
|
]
|
||||||
|
|
||||||
combined_xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE)
|
# Compile patterns
|
||||||
combined_sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE)
|
xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE)
|
||||||
combined_webshells_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE)
|
sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE)
|
||||||
|
webshell_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE)
|
||||||
with open(log_file_path, 'r') as log_file:
|
|
||||||
log_lines = log_file.readlines()
|
try:
|
||||||
|
with open(log_file_path, 'r', encoding='utf-8', errors='ignore') as log_file:
|
||||||
|
log_lines = log_file.readlines()
|
||||||
|
|
||||||
for line in log_lines:
|
for line in log_lines:
|
||||||
if " 403 " in line: # Check if the line contains " 403 " indicating a 403 status code
|
if not line.strip():
|
||||||
match = re.search(r'(\w+\s+\d+\s\d+:\d+:\d+).*\s(\d+\.\d+\.\d+\.\d+).*"\s*(GET|POST|PUT|DELETE)\s+([^"]+)"', line)
|
continue
|
||||||
if match:
|
|
||||||
timestamp = match.group(1) # Extract the date and time
|
|
||||||
ip_address = match.group(2)
|
|
||||||
http_method = match.group(3)
|
|
||||||
requested_url = match.group(4)
|
|
||||||
|
|
||||||
if combined_xss_pattern.search(line):
|
try:
|
||||||
xss_alert = 'Possible XSS Attack Was Identified.'
|
# Extract syslog header
|
||||||
|
syslog_match = re.search(
|
||||||
|
r'<\d+>(\w+\s+\d+\s+\d+:\d+:\d+).*haproxy\[\d+\]:\s+',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
|
||||||
|
if not syslog_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = syslog_match.group(1)
|
||||||
|
|
||||||
|
# Extract IP:PORT
|
||||||
|
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+):(\d+)', line)
|
||||||
|
if not ip_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip_address = ip_match.group(1)
|
||||||
|
|
||||||
|
# Extract date/time in brackets (preferred format)
|
||||||
|
datetime_match = re.search(r'\[(\d{2}/\w+/\d{4}:\d{2}:\d{2}:\d{2})', line)
|
||||||
|
if datetime_match:
|
||||||
|
timestamp = datetime_match.group(1)
|
||||||
|
|
||||||
|
# Extract frontend and backend
|
||||||
|
fe_be_match = re.search(r'\]\s+(\S+)\s+(\S+)\s+(\d+/\d+/\d+/\d+/\d+)\s+(\d{3})', line)
|
||||||
|
if not fe_be_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frontend = fe_be_match.group(1)
|
||||||
|
backend = fe_be_match.group(2)
|
||||||
|
status_code = fe_be_match.group(4)
|
||||||
|
|
||||||
|
# Extract HTTP method and URL
|
||||||
|
http_match = re.search(r'"(\w+)\s+([^\s]+)\s+HTTP', line)
|
||||||
|
if not http_match:
|
||||||
|
# Fallback: extract entire request line
|
||||||
|
request_match = re.search(r'"([^"]*)"', line)
|
||||||
|
if request_match:
|
||||||
|
request_line = request_match.group(1).split()
|
||||||
|
http_method = request_line[0] if len(request_line) > 0 else 'UNKNOWN'
|
||||||
|
requested_url = request_line[1] if len(request_line) > 1 else '/'
|
||||||
else:
|
else:
|
||||||
xss_alert = ''
|
continue
|
||||||
if combined_sql_pattern.search(line):
|
else:
|
||||||
sql_alert = 'Possible SQL Injection Attempt Was Made.'
|
http_method = http_match.group(1)
|
||||||
else:
|
requested_url = http_match.group(2)
|
||||||
sql_alert = ''
|
|
||||||
if "PUT" in line:
|
# Detect threats
|
||||||
put_method = 'Possible Remote File Upload Attempt Was Made.'
|
xss_alert = bool(xss_pattern.search(line))
|
||||||
else:
|
sql_alert = bool(sql_pattern.search(line))
|
||||||
put_method = ''
|
webshell_alert = bool(webshell_pattern.search(line))
|
||||||
|
put_method = http_method == 'PUT'
|
||||||
if "admin" in line:
|
illegal_resource = status_code == '403'
|
||||||
illegal_resource = 'Possible Illegal Resource Access Attempt Was Made.'
|
|
||||||
else:
|
# Determine status class for UI coloring
|
||||||
illegal_resource = ''
|
status_class = 'secondary'
|
||||||
|
if status_code.startswith('2'):
|
||||||
if combined_webshells_pattern.search(line):
|
status_class = 'success'
|
||||||
webshell_alert = 'Possible WebShell Attack Attempt Was Made.'
|
elif status_code.startswith('3'):
|
||||||
else:
|
status_class = 'info'
|
||||||
webshell_alert = ''
|
elif status_code.startswith('4'):
|
||||||
|
status_class = 'warning'
|
||||||
parsed_entries.append({
|
if illegal_resource:
|
||||||
'timestamp': timestamp,
|
status_class = 'warning'
|
||||||
'ip_address': ip_address,
|
elif status_code.startswith('5'):
|
||||||
'http_method': http_method,
|
status_class = 'danger'
|
||||||
'requested_url': requested_url,
|
|
||||||
'xss_alert': xss_alert,
|
# Add threat flag if any security issue detected
|
||||||
'sql_alert': sql_alert,
|
has_threat = xss_alert or sql_alert or webshell_alert or put_method or illegal_resource
|
||||||
'put_method': put_method,
|
if has_threat:
|
||||||
'illegal_resource': illegal_resource,
|
status_class = 'danger'
|
||||||
'webshell_alert': webshell_alert
|
|
||||||
})
|
parsed_entries.append({
|
||||||
return parsed_entries
|
'timestamp': timestamp,
|
||||||
|
'ip_address': ip_address,
|
||||||
|
'http_method': http_method,
|
||||||
|
'requested_url': requested_url,
|
||||||
|
'status_code': status_code,
|
||||||
|
'frontend': frontend,
|
||||||
|
'backend': backend,
|
||||||
|
'xss_alert': xss_alert,
|
||||||
|
'sql_alert': sql_alert,
|
||||||
|
'put_method': put_method,
|
||||||
|
'illegal_resource': illegal_resource,
|
||||||
|
'webshell_alert': webshell_alert,
|
||||||
|
'status_class': status_class,
|
||||||
|
'has_threat': has_threat,
|
||||||
|
'message': f"{frontend}~ {backend} [{status_code}] {http_method} {requested_url}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LOG_PARSER] Error parsing line: {e}", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"[LOG_PARSER] Log file not found: {log_file_path}", flush=True)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LOG_PARSER] Error reading log file: {e}", flush=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"[LOG_PARSER] Parsed {len(parsed_entries)} log entries", flush=True)
|
||||||
|
return parsed_entries
|
||||||
|
|||||||
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
|
requests
|
||||||
pyOpenSSL
|
Jinja2
|
||||||
Werkzeug
|
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,91 +1,21 @@
|
|||||||
from flask import Blueprint, render_template, request
|
"""Edit HAProxy configuration"""
|
||||||
import subprocess
|
|
||||||
from auth.auth_middleware import requires_auth
|
|
||||||
|
|
||||||
edit_bp = Blueprint('edit', __name__)
|
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for
|
||||||
|
from routes.auth_routes import login_required
|
||||||
|
from database.models import VirtualHost
|
||||||
|
import logging
|
||||||
|
|
||||||
@edit_bp.route('/edit', methods=['GET', 'POST'])
|
edit_bp = Blueprint('edit', __name__, url_prefix='/edit')
|
||||||
@requires_auth
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@edit_bp.route('/')
|
||||||
|
@login_required
|
||||||
def edit_haproxy_config():
|
def edit_haproxy_config():
|
||||||
if request.method == 'POST':
|
"""Edit configuration page"""
|
||||||
edited_config = request.form['haproxy_config']
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open('/etc/haproxy/haproxy.cfg', 'w') as f:
|
|
||||||
f.write(edited_config)
|
|
||||||
except Exception as e:
|
|
||||||
return render_template(
|
|
||||||
'edit.html',
|
|
||||||
config_content=edited_config,
|
|
||||||
check_output=f"Error writing configuration: {e}",
|
|
||||||
check_level="danger"
|
|
||||||
)
|
|
||||||
|
|
||||||
def run_check():
|
|
||||||
result = subprocess.run(
|
|
||||||
['haproxy', '-c', '-V', '-f', '/etc/haproxy/haproxy.cfg'],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
out = (result.stdout or '').strip()
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
if not out:
|
|
||||||
out = "Configuration file is valid ✅"
|
|
||||||
level = "success"
|
|
||||||
if "Warning" in out or "Warnings" in out:
|
|
||||||
level = "warning"
|
|
||||||
else:
|
|
||||||
if not out:
|
|
||||||
out = f"Check failed with return code {result.returncode}"
|
|
||||||
level = "danger"
|
|
||||||
|
|
||||||
return result.returncode, out, level
|
|
||||||
|
|
||||||
check_output = ""
|
|
||||||
check_level = "success"
|
|
||||||
|
|
||||||
if 'save_check' in request.form:
|
|
||||||
_, check_output, check_level = run_check()
|
|
||||||
|
|
||||||
elif 'save_reload' in request.form:
|
|
||||||
rc, out, level = run_check()
|
|
||||||
check_output, check_level = out, level
|
|
||||||
|
|
||||||
if rc == 0:
|
|
||||||
try:
|
|
||||||
supervisor_result = subprocess.run(
|
|
||||||
['pkill', '-f', 'haproxy'],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if supervisor_result.returncode == 0:
|
|
||||||
check_output += f"\n\nHAProxy Restarted:\n{supervisor_result.stdout}"
|
|
||||||
else:
|
|
||||||
check_output += (
|
|
||||||
f"\n\nRestart attempt returned {supervisor_result.returncode}:\n"
|
|
||||||
f"{supervisor_result.stdout}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
check_output += f"\n\nRestart failed: {e}"
|
|
||||||
check_level = "warning"
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'edit.html',
|
|
||||||
config_content=edited_config,
|
|
||||||
check_output=check_output,
|
|
||||||
check_level=check_level
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open('/etc/haproxy/haproxy.cfg', 'r') as f:
|
vhosts = VirtualHost.query.all()
|
||||||
config_content = f.read()
|
return render_template('edit.html', vhosts=vhosts)
|
||||||
except FileNotFoundError:
|
except Exception as e:
|
||||||
config_content = "# HAProxy configuration file not found\n# Please create /etc/haproxy/haproxy.cfg"
|
logger.error(f"[EDIT] Error: {e}", flush=True)
|
||||||
except PermissionError:
|
return render_template('edit.html', vhosts=[], error=str(e))
|
||||||
config_content = "# Permission denied reading HAProxy configuration file"
|
|
||||||
|
|
||||||
return render_template('edit.html', config_content=config_content)
|
|
||||||
|
|||||||
@@ -1,104 +1,39 @@
|
|||||||
from flask import Blueprint, render_template, request
|
"""Main routes - Dashboard, Home"""
|
||||||
from auth.auth_middleware import requires_auth # Updated import
|
|
||||||
from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends
|
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__)
|
main_bp = Blueprint('main', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
@main_bp.route('/', methods=['GET', 'POST'])
|
|
||||||
@requires_auth
|
|
||||||
def index():
|
def index():
|
||||||
if request.method == 'POST':
|
"""Dashboard - list vhosts"""
|
||||||
frontend_name = request.form['frontend_name']
|
if 'user_id' not in session:
|
||||||
frontend_ip = request.form['frontend_ip']
|
return redirect(url_for('auth.login'))
|
||||||
frontend_port = request.form['frontend_port']
|
|
||||||
lb_method = request.form['lb_method']
|
return render_template('dashboard.html')
|
||||||
protocol = request.form['protocol']
|
|
||||||
backend_name = request.form['backend_name']
|
|
||||||
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 ''
|
|
||||||
|
|
||||||
|
|
||||||
# Get all backend servers data
|
|
||||||
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[]')
|
|
||||||
|
|
||||||
is_acl = 'add_acl' in request.form
|
@main_bp.route('/home')
|
||||||
acl_name = request.form['acl'] if 'acl' in request.form else ''
|
@login_required
|
||||||
acl_action = request.form['acl_action'] if 'acl_action' in request.form else ''
|
def home():
|
||||||
acl_backend_name = request.form['backend_name_acl'] if 'backend_name_acl' in request.form else ''
|
"""Home - alias for dashboard"""
|
||||||
use_ssl = 'ssl_checkbox' in request.form
|
return redirect(url_for('main.index'))
|
||||||
ssl_cert_path = request.form['ssl_cert_path']
|
|
||||||
https_redirect = 'ssl_redirect_checkbox' in request.form
|
|
||||||
is_dos = 'add_dos' in request.form if 'add_dos' in request.form else ''
|
|
||||||
ban_duration = request.form["ban_duration"]
|
|
||||||
limit_requests = request.form["limit_requests"]
|
|
||||||
forward_for = 'forward_for_check' in request.form
|
|
||||||
|
|
||||||
is_forbidden_path = 'add_acl_path' in request.form
|
|
||||||
forbidden_name = request.form["forbidden_name"]
|
|
||||||
allowed_ip = request.form["allowed_ip"]
|
|
||||||
forbidden_path = request.form["forbidden_path"]
|
|
||||||
|
|
||||||
sql_injection_check = 'sql_injection_check' in request.form if 'sql_injection_check' in request.form else ''
|
@main_bp.route('/display_logs')
|
||||||
is_xss = 'xss_check' in request.form if 'xss_check' in request.form else ''
|
@login_required
|
||||||
is_remote_upload = 'remote_uploads_check' in request.form if 'remote_uploads_check' in request.form else ''
|
def display_logs():
|
||||||
|
"""Display HAProxy logs"""
|
||||||
|
return render_template('logs.html')
|
||||||
|
|
||||||
add_path_based = 'add_path_based' in request.form
|
|
||||||
redirect_domain_name = request.form["redirect_domain_name"]
|
|
||||||
root_redirect = request.form["root_redirect"]
|
|
||||||
redirect_to = request.form["redirect_to"]
|
|
||||||
is_webshells = 'webshells_check' in request.form if 'webshells_check' in request.form else ''
|
|
||||||
|
|
||||||
# Combine backend server info into a list of tuples (name, ip, port, maxconns)
|
|
||||||
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: # Only add if we have IP and port
|
|
||||||
backend_servers.append((name, ip, port, maxconn))
|
|
||||||
|
|
||||||
# Check if frontend or port already exists
|
|
||||||
if is_frontend_exist(frontend_name, frontend_ip, frontend_port):
|
|
||||||
return render_template('index.html', message="Frontend or Port already exists. Cannot add duplicate.")
|
|
||||||
|
|
||||||
# Get health check related fields if the protocol is HTTP
|
|
||||||
health_check = False
|
|
||||||
health_check_link = ""
|
|
||||||
if protocol == 'http':
|
|
||||||
health_check = 'health_check' in request.form
|
|
||||||
if health_check:
|
|
||||||
health_check_link = request.form['health_check_link']
|
|
||||||
|
|
||||||
health_check_tcp = False
|
|
||||||
if protocol == 'tcp':
|
|
||||||
health_check_tcp = 'health_check2' in request.form
|
|
||||||
|
|
||||||
# Get sticky session related fields
|
|
||||||
sticky_session = False
|
|
||||||
sticky_session_type = ""
|
|
||||||
if 'sticky_session' in request.form:
|
|
||||||
sticky_session = True
|
|
||||||
sticky_session_type = request.form['sticky_session_type']
|
|
||||||
|
|
||||||
# Update the HAProxy config file
|
|
||||||
message = update_haproxy_config(
|
|
||||||
frontend_name, frontend_ip, frontend_port, lb_method, protocol, backend_name,
|
|
||||||
backend_servers, health_check, health_check_tcp, health_check_link, sticky_session,
|
|
||||||
add_header, header_name, header_value, sticky_session_type, is_acl, acl_name,
|
|
||||||
acl_action, acl_backend_name, use_ssl, ssl_cert_path, https_redirect, is_dos,
|
|
||||||
ban_duration, limit_requests, forward_for, is_forbidden_path, forbidden_name,
|
|
||||||
allowed_ip, forbidden_path, sql_injection_check, is_xss, is_remote_upload,
|
|
||||||
add_path_based, redirect_domain_name, root_redirect, redirect_to, is_webshells
|
|
||||||
)
|
|
||||||
return render_template('index.html', message=message)
|
|
||||||
|
|
||||||
return render_template('index.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
|
||||||
4
spawn.sh
4
spawn.sh
@@ -1,3 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
git pull
|
||||||
docker compose down
|
docker compose down
|
||||||
docker compose up --build
|
docker compose up --remove-orphans --build --no-deps --force-recreate
|
||||||
|
|
||||||
|
|||||||
48
static/css/edit.css
Normal file
48
static/css/edit.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.CodeMirror {
|
||||||
|
height: 500px !important;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-gutters {
|
||||||
|
background-color: #263238;
|
||||||
|
border-right: 1px solid #37474f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-linenumber {
|
||||||
|
color: #546e7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CodeMirror-cursor {
|
||||||
|
border-left: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#haproxy_config {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit_form button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.CodeMirror {
|
||||||
|
height: 300px !important;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#haproxy_config {
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
133
static/js/editor.js
Normal file
133
static/js/editor.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* HAProxy Configuration Editor
|
||||||
|
* Auto-grow textarea + CodeMirror integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-grow textarea (fallback if CodeMirror fails)
|
||||||
|
initAutoGrowTextarea();
|
||||||
|
|
||||||
|
// Try to initialize CodeMirror
|
||||||
|
initCodeMirror();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auto-grow textarea
|
||||||
|
*/
|
||||||
|
function initAutoGrowTextarea() {
|
||||||
|
'use strict';
|
||||||
|
const ta = document.getElementById('haproxy_config');
|
||||||
|
if (!ta) return;
|
||||||
|
|
||||||
|
const autoGrow = () => {
|
||||||
|
ta.style.height = 'auto';
|
||||||
|
ta.style.height = (ta.scrollHeight + 6) + 'px';
|
||||||
|
};
|
||||||
|
|
||||||
|
ta.addEventListener('input', autoGrow);
|
||||||
|
ta.addEventListener('change', autoGrow);
|
||||||
|
|
||||||
|
// Initial auto-size
|
||||||
|
autoGrow();
|
||||||
|
|
||||||
|
// Resize on window resize
|
||||||
|
window.addEventListener('resize', autoGrow);
|
||||||
|
|
||||||
|
console.log('[Editor] Auto-grow textarea initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize CodeMirror editor
|
||||||
|
*/
|
||||||
|
function initCodeMirror() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Check if CodeMirror is available
|
||||||
|
if (typeof CodeMirror === 'undefined') {
|
||||||
|
console.warn('[Editor] CodeMirror not loaded, using fallback textarea');
|
||||||
|
document.getElementById('haproxy_config').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const editorElement = document.getElementById('haproxy_editor');
|
||||||
|
if (!editorElement) {
|
||||||
|
console.warn('[Editor] haproxy_editor element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = CodeMirror.fromTextArea(editorElement, {
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: true,
|
||||||
|
indentUnit: 4,
|
||||||
|
indentWithTabs: false,
|
||||||
|
theme: 'material-darker',
|
||||||
|
mode: 'text/x-nginx-conf',
|
||||||
|
styleActiveLine: true,
|
||||||
|
styleSelectedText: true,
|
||||||
|
highlightSelectionMatches: { annotateScrollbar: true },
|
||||||
|
foldGutter: true,
|
||||||
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
|
||||||
|
matchBrackets: true,
|
||||||
|
autoCloseBrackets: true,
|
||||||
|
extraKeys: {
|
||||||
|
'Ctrl-S': function() {
|
||||||
|
document.querySelector('button[value="save"]').click();
|
||||||
|
},
|
||||||
|
'Ctrl-L': function() {
|
||||||
|
editor.clearHistory();
|
||||||
|
},
|
||||||
|
'Ctrl-/': 'toggleComment'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide fallback textarea
|
||||||
|
document.getElementById('haproxy_config').style.display = 'none';
|
||||||
|
|
||||||
|
// Update line/col info
|
||||||
|
editor.on('cursorActivity', function() {
|
||||||
|
const pos = editor.getCursor();
|
||||||
|
document.getElementById('line_col').textContent =
|
||||||
|
`Line ${pos.line + 1}, Col ${pos.ch + 1}`;
|
||||||
|
document.getElementById('char_count').textContent =
|
||||||
|
editor.getValue().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save to localStorage
|
||||||
|
let saveTimeout;
|
||||||
|
editor.on('change', function() {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(() => {
|
||||||
|
localStorage.setItem('haproxy_draft', editor.getValue());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recover from localStorage
|
||||||
|
const draft = localStorage.getItem('haproxy_draft');
|
||||||
|
const currentContent = editorElement.value.trim();
|
||||||
|
|
||||||
|
if (draft && draft.trim() !== currentContent && currentContent === '') {
|
||||||
|
if (confirm('📝 Recover unsaved draft?')) {
|
||||||
|
editor.setValue(draft);
|
||||||
|
localStorage.removeItem('haproxy_draft');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission - sync values
|
||||||
|
const editForm = document.getElementById('edit_form');
|
||||||
|
editForm.addEventListener('submit', function(e) {
|
||||||
|
editorElement.value = editor.getValue();
|
||||||
|
document.getElementById('haproxy_config').value = editor.getValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial info
|
||||||
|
document.getElementById('char_count').textContent = editor.getValue().length;
|
||||||
|
|
||||||
|
console.log('[Editor] CodeMirror initialized successfully');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Editor] CodeMirror initialization failed:', e);
|
||||||
|
// Fallback textarea is already visible
|
||||||
|
document.getElementById('haproxy_config').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
137
static/js/form.js
Normal file
137
static/js/form.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Helper functions (shared)
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
|
||||||
|
// ===== HEALTH CHECK FIELDS (Protocol-dependent) =====
|
||||||
|
const protocolSelect = document.getElementById('protocol');
|
||||||
|
const healthCheckFields = document.getElementById('health_check_fields');
|
||||||
|
const tcpHealthCheck = document.getElementById('tcp_health_check');
|
||||||
|
|
||||||
|
const onProtocolChange = () => {
|
||||||
|
if (protocolSelect?.value === 'http') {
|
||||||
|
const healthCheckParent = document.getElementById('health_check')?.parentElement.parentElement;
|
||||||
|
if (healthCheckParent) healthCheckParent.style.display = 'block';
|
||||||
|
if (tcpHealthCheck) tcpHealthCheck.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
const healthCheckParent = document.getElementById('health_check')?.parentElement.parentElement;
|
||||||
|
if (healthCheckParent) healthCheckParent.style.display = 'none';
|
||||||
|
if (tcpHealthCheck) tcpHealthCheck.style.display = 'flex';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protocolSelect?.addEventListener('change', onProtocolChange);
|
||||||
|
|
||||||
|
// ===== STICKY SESSION FIELDS =====
|
||||||
|
const stickyCheckbox = document.getElementById('sticky_session');
|
||||||
|
const stickyFields = document.getElementById('sticky_fields');
|
||||||
|
|
||||||
|
stickyCheckbox?.addEventListener('change', function() {
|
||||||
|
stickyFields?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== HEALTH CHECK LINK FIELD =====
|
||||||
|
const healthCheckbox = document.getElementById('health_check');
|
||||||
|
healthCheckbox?.addEventListener('change', function() {
|
||||||
|
document.getElementById('health_check_fields')?.classList.toggle('d-none', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== CUSTOM HEADER FIELDS =====
|
||||||
|
const headerCheckbox = document.getElementById('add_header');
|
||||||
|
const headerFields = document.querySelectorAll('#header_fields');
|
||||||
|
|
||||||
|
headerCheckbox?.addEventListener('change', function() {
|
||||||
|
headerFields.forEach(field => field.classList.toggle('d-none', !this.checked));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== NO-LB MODE HANDLING =====
|
||||||
|
const lbMethodSelect = $('#lb_method');
|
||||||
|
const backendServersContainer = $('#backend_servers_container');
|
||||||
|
const addServerBtn = $('#add_backend_btn');
|
||||||
|
|
||||||
|
const onLbMethodChange = () => {
|
||||||
|
const isNoLb = lbMethodSelect?.value === 'no-lb';
|
||||||
|
|
||||||
|
if (isNoLb) {
|
||||||
|
// Hide add server button
|
||||||
|
if (addServerBtn) addServerBtn.classList.add('d-none');
|
||||||
|
|
||||||
|
// Keep only first server and remove others
|
||||||
|
const serverRows = $$('.backend-server-row', backendServersContainer);
|
||||||
|
serverRows.forEach((row, idx) => {
|
||||||
|
if (idx > 0) row.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add info about no-lb mode if it doesn't exist
|
||||||
|
if (!$('.no-lb-info')) {
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'alert alert-info alert-sm no-lb-info mt-2';
|
||||||
|
info.innerHTML = '<i class="bi bi-info-circle me-2"></i><small>Mode <strong>no-lb</strong>: frontend → backend → single server. You can still enable XSS, DOS, SQL injection protection etc.</small>';
|
||||||
|
if (backendServersContainer?.parentElement) {
|
||||||
|
backendServersContainer.parentElement.appendChild(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show add server button
|
||||||
|
if (addServerBtn) addServerBtn.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Remove no-lb info
|
||||||
|
const info = $('.no-lb-info');
|
||||||
|
if (info) info.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lbMethodSelect?.addEventListener('change', onLbMethodChange);
|
||||||
|
if (lbMethodSelect) onLbMethodChange();
|
||||||
|
|
||||||
|
// ===== BACKEND SERVER ROWS (Dynamic Add/Remove) =====
|
||||||
|
let serverCount = 1;
|
||||||
|
const container = $('#backend_servers_container');
|
||||||
|
const addBtn = $('#add_backend_btn');
|
||||||
|
|
||||||
|
const createRow = () => {
|
||||||
|
serverCount++;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row g-3 backend-server-row mt-1';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" class="form-control" name="backend_server_names[]" placeholder="server${serverCount}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" class="form-control" name="backend_server_ips[]" placeholder="192.168.1.${serverCount}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" class="form-control" name="backend_server_ports[]" placeholder="80" min="1" max="65535" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" class="form-control" name="backend_server_maxconns[]" placeholder="100">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm w-100 remove-server">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const removeBtn = row.querySelector('.remove-server');
|
||||||
|
removeBtn.addEventListener('click', () => row.remove());
|
||||||
|
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
addBtn?.addEventListener('click', () => {
|
||||||
|
if (container) {
|
||||||
|
container.appendChild(createRow());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove button for dynamically added rows
|
||||||
|
container?.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.remove-server')) {
|
||||||
|
e.target.closest('.backend-server-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,105 +1,82 @@
|
|||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const $ = (sel, root=document) => root.querySelector(sel);
|
// ===== HELPER FUNCTIONS =====
|
||||||
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
// SSL fields
|
const toggle = (on, el) => el && el.classList.toggle('d-none', !on);
|
||||||
const sslCheckbox = $('#ssl_checkbox');
|
|
||||||
const sslFields = $('#ssl_fields');
|
// ===== SSL FIELDS =====
|
||||||
|
const sslCheckbox = $('#ssl_checkbox');
|
||||||
const toggle = (on, el) => el.classList.toggle('d-none', !on);
|
const sslFields = $('#ssl_fields');
|
||||||
|
sslCheckbox?.addEventListener('change', () => toggle(sslCheckbox.checked, sslFields));
|
||||||
sslCheckbox?.addEventListener('change', () => toggle(sslCheckbox.checked, sslFields));
|
|
||||||
|
// ===== DOS PROTECTION =====
|
||||||
// DOS
|
const dosCheckbox = $('#add_dos');
|
||||||
const dosCheckbox = $('#add_dos');
|
const dosFields = $('#dos_fields');
|
||||||
const dosFields = $('#dos_fields');
|
dosCheckbox?.addEventListener('change', () => toggle(dosCheckbox.checked, dosFields));
|
||||||
dosCheckbox?.addEventListener('change', () => toggle(dosCheckbox.checked, dosFields));
|
|
||||||
|
// ===== PROTOCOL CHANGE (HTTP/TCP) =====
|
||||||
// HTTP only groups
|
const protocolSelect = $('#protocol');
|
||||||
const protocolSelect = $('#protocol');
|
const httpGroups = $$('.http-only, #forbidden_acl_container');
|
||||||
const httpGroups = $$('.http-only, #forbidden_acl_container');
|
const httpToggles = [
|
||||||
const httpToggles = [
|
$('#sql_injection_check'),
|
||||||
$('#sql_injection_check'),
|
$('#xss_check'),
|
||||||
$('#xss_check'),
|
$('#remote_uploads_check'),
|
||||||
$('#remote_uploads_check'),
|
$('#webshells_check'),
|
||||||
$('#webshells_check'),
|
$('#forward_for_check'),
|
||||||
$('#forward_for_check'),
|
$('#add_acl_path'),
|
||||||
$('#add_acl_path'),
|
$('#add_path_based'),
|
||||||
$('#add_path_based'),
|
$('#add_custom_acl'),
|
||||||
];
|
];
|
||||||
const forbiddenFields = $('#forbidden_fields');
|
|
||||||
const pathFields = $('#base_redirect_fields');
|
const forbiddenFields = $('#forbidden_fields');
|
||||||
|
const pathFields = $('#base_redirect_fields');
|
||||||
const onProtocolChange = () => {
|
|
||||||
const isHttp = protocolSelect?.value === 'http';
|
const onProtocolChange = () => {
|
||||||
httpGroups.forEach(el => toggle(isHttp, el));
|
const isHttp = protocolSelect?.value === 'http';
|
||||||
if (!isHttp) {
|
httpGroups.forEach(el => toggle(isHttp, el));
|
||||||
// hide optional groups if protocol != http
|
|
||||||
[forbiddenFields, pathFields].forEach(el => el && toggle(false, el));
|
if (!isHttp) {
|
||||||
httpToggles.forEach(input => { if (input) input.checked = false; });
|
[forbiddenFields, pathFields].forEach(el => toggle(false, el));
|
||||||
}
|
httpToggles.forEach(input => {
|
||||||
};
|
if (input) input.checked = false;
|
||||||
protocolSelect?.addEventListener('change', onProtocolChange);
|
});
|
||||||
onProtocolChange();
|
}
|
||||||
|
};
|
||||||
// ACL
|
|
||||||
const aclCheckbox = $('#add_acl');
|
protocolSelect?.addEventListener('change', onProtocolChange);
|
||||||
const aclFields = $('#acl_fields');
|
onProtocolChange();
|
||||||
aclCheckbox?.addEventListener('change', () => toggle(aclCheckbox.checked, aclFields));
|
|
||||||
|
// ===== BACKEND SSL REDIRECT =====
|
||||||
// toggles that reveal their fields
|
const backendSslCheckbox = $('#backend_ssl_redirect');
|
||||||
const bindToggle = (checkboxSel, targetSel) => {
|
const backendSslFields = $('#backend_ssl_fields');
|
||||||
const cb = $(checkboxSel);
|
|
||||||
const target = $(targetSel);
|
backendSslCheckbox?.addEventListener('change', function() {
|
||||||
cb?.addEventListener('change', () => toggle(cb.checked, target));
|
toggle(this.checked, backendSslFields);
|
||||||
// initial
|
|
||||||
if (cb && target) toggle(cb.checked, target);
|
|
||||||
};
|
|
||||||
bindToggle('#add_path_based', '#base_redirect_fields');
|
|
||||||
bindToggle('#add_acl_path', '#forbidden_fields');
|
|
||||||
|
|
||||||
// Backend rows
|
|
||||||
let serverCount = 1;
|
|
||||||
const container = $('#backend_servers_container');
|
|
||||||
const addBtn = $('#add_backend_btn');
|
|
||||||
|
|
||||||
const createRow = () => {
|
|
||||||
serverCount++;
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'row g-3 backend-server-row mt-1';
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="name${serverCount}">Nazwa serwera</label>
|
|
||||||
<input type="text" id="name${serverCount}" class="form-control" name="backend_server_names[]" placeholder="server${serverCount}" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="ip${serverCount}">IP</label>
|
|
||||||
<input type="text" id="ip${serverCount}" class="form-control" name="backend_server_ips[]" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="port${serverCount}">Port</label>
|
|
||||||
<input type="number" id="port${serverCount}" class="form-control" name="backend_server_ports[]" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="maxconn${serverCount}">MaxConn</label>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<input type="number" id="maxconn${serverCount}" class="form-control" name="backend_server_maxconns[]">
|
|
||||||
<button type="button" class="btn btn-danger" title="Usuń">Usuń</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
row.querySelector('button.btn-danger')?.addEventListener('click', () => {
|
|
||||||
const rows = $$('.backend-server-row');
|
|
||||||
if (rows.length > 1) row.remove();
|
|
||||||
else alert('Musi istnieć co najmniej jeden backend.');
|
|
||||||
});
|
});
|
||||||
return row;
|
|
||||||
};
|
// ===== CUSTOM ACL (Main Toggle) =====
|
||||||
addBtn?.addEventListener('click', () => container?.appendChild(createRow()));
|
const customAclCheckbox = $('#add_custom_acl');
|
||||||
|
const customAclFields = $('#custom_acl_fields');
|
||||||
// auto dismiss alerts
|
|
||||||
setTimeout(() => $$('.alert').forEach(a => {
|
customAclCheckbox?.addEventListener('change', function() {
|
||||||
if (typeof bootstrap !== 'undefined') new bootstrap.Alert(a).close();
|
toggle(this.checked, customAclFields);
|
||||||
}), 5000);
|
});
|
||||||
|
|
||||||
|
// ===== CUSTOM ACL Action Type Toggle =====
|
||||||
|
const customAclAction = $('#custom_acl_action');
|
||||||
|
const aclBackendSelect = $('#acl_backend_select');
|
||||||
|
const aclRedirectSelect = $('#acl_redirect_select');
|
||||||
|
|
||||||
|
const onCustomAclActionChange = () => {
|
||||||
|
const action = customAclAction?.value;
|
||||||
|
toggle(action === 'route', aclBackendSelect);
|
||||||
|
toggle(action === 'redirect', aclRedirectSelect);
|
||||||
|
};
|
||||||
|
|
||||||
|
customAclAction?.addEventListener('change', onCustomAclActionChange);
|
||||||
|
// Initial state
|
||||||
|
onCustomAclActionChange();
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* HAProxy Logs Management with Security Alerts
|
||||||
|
* Fixed pagination
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let currentPage = 1;
|
||||||
|
let perPage = 50;
|
||||||
|
let totalLogs = parseInt(document.getElementById('total_count').textContent);
|
||||||
|
let allLoadedLogs = [];
|
||||||
|
let excludePhrases = [];
|
||||||
|
|
||||||
|
const logsContainer = document.getElementById('logs_container');
|
||||||
|
const searchFilter = document.getElementById('search_filter');
|
||||||
|
const excludeFilter = document.getElementById('exclude_filter');
|
||||||
|
const excludeBtn = document.getElementById('exclude_btn');
|
||||||
|
const perPageSelect = document.getElementById('logs_per_page');
|
||||||
|
const refreshBtn = document.getElementById('refresh_logs_btn');
|
||||||
|
const prevBtn = document.getElementById('prev_btn');
|
||||||
|
const nextBtn = document.getElementById('next_btn');
|
||||||
|
const loadAllBtn = document.getElementById('load_all_btn');
|
||||||
|
const clearFilterBtn = document.getElementById('clear_filter_btn');
|
||||||
|
const loadedSpan = document.getElementById('loaded_count');
|
||||||
|
const matchSpan = document.getElementById('match_count');
|
||||||
|
const currentPageSpan = document.getElementById('current_page');
|
||||||
|
const totalPagesSpan = document.getElementById('total_pages');
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
searchFilter.addEventListener('keyup', debounce(function() {
|
||||||
|
console.log('[Logs] Search changed');
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
}, 300));
|
||||||
|
|
||||||
|
excludeBtn.addEventListener('click', function() {
|
||||||
|
const phrase = excludeFilter.value.trim();
|
||||||
|
if (phrase) {
|
||||||
|
if (!excludePhrases.includes(phrase)) {
|
||||||
|
excludePhrases.push(phrase);
|
||||||
|
updateExcludeUI();
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
}
|
||||||
|
excludeFilter.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
excludeFilter.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') excludeBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearFilterBtn.addEventListener('click', function() {
|
||||||
|
console.log('[Logs] Clear filters');
|
||||||
|
searchFilter.value = '';
|
||||||
|
excludePhrases = [];
|
||||||
|
excludeFilter.value = '';
|
||||||
|
updateExcludeUI();
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
perPageSelect.addEventListener('change', function() {
|
||||||
|
console.log(`[Logs] Per page changed to ${this.value}`);
|
||||||
|
perPage = parseInt(this.value);
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', function() {
|
||||||
|
console.log('[Logs] Refresh clicked');
|
||||||
|
searchFilter.value = '';
|
||||||
|
excludePhrases = [];
|
||||||
|
excludeFilter.value = '';
|
||||||
|
updateExcludeUI();
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', function() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
console.log(`[Logs] Prev button: page ${currentPage} -> ${currentPage - 1}`);
|
||||||
|
currentPage--;
|
||||||
|
loadLogsWithPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextBtn.addEventListener('click', function() {
|
||||||
|
const totalPages = parseInt(document.getElementById('total_pages').textContent);
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
console.log(`[Logs] Next button: page ${currentPage} -> ${currentPage + 1}`);
|
||||||
|
currentPage++;
|
||||||
|
loadLogsWithPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadAllBtn.addEventListener('click', function() {
|
||||||
|
console.log('[Logs] Load all clicked');
|
||||||
|
perPage = totalLogs > 500 ? 500 : totalLogs;
|
||||||
|
currentPage = 1;
|
||||||
|
perPageSelect.value = perPage;
|
||||||
|
loadLogsWithPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function
|
||||||
|
*/
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(func, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load logs with pagination from API
|
||||||
|
*/
|
||||||
|
function loadLogsWithPage() {
|
||||||
|
console.log(`[Logs] loadLogsWithPage: page=${currentPage}, per_page=${perPage}, search="${searchFilter.value.trim()}", exclude=${excludePhrases.length}`);
|
||||||
|
|
||||||
|
logsContainer.innerHTML = '<tr><td class="text-center text-muted py-4">Loading logs...</td></tr>';
|
||||||
|
|
||||||
|
fetch('/api/logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchFilter.value.trim(),
|
||||||
|
exclude: excludePhrases
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('[Logs] API Response:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
allLoadedLogs = data.logs;
|
||||||
|
loadedSpan.textContent = data.loaded_count;
|
||||||
|
totalLogs = data.total;
|
||||||
|
document.getElementById('total_count').textContent = data.total;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(data.total_filtered / perPage) || 1;
|
||||||
|
totalPagesSpan.textContent = totalPages;
|
||||||
|
matchSpan.textContent = data.total_filtered;
|
||||||
|
currentPageSpan.textContent = data.page;
|
||||||
|
|
||||||
|
renderLogs(data.logs);
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
prevBtn.disabled = currentPage === 1;
|
||||||
|
nextBtn.disabled = !data.has_more;
|
||||||
|
|
||||||
|
console.log(`[Logs] Updated: page ${data.page}/${totalPages}, has_more=${data.has_more}, prev_disabled=${prevBtn.disabled}, next_disabled=${nextBtn.disabled}`);
|
||||||
|
} else {
|
||||||
|
showError(data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('[Logs] Error:', e);
|
||||||
|
showError('Failed to load logs: ' + e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render logs as table rows
|
||||||
|
*/
|
||||||
|
function renderLogs(logs) {
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
logsContainer.innerHTML = '<tr><td class="text-center text-muted py-4">No logs found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logsContainer.innerHTML = logs.map((entry) => {
|
||||||
|
const threat_badges = [];
|
||||||
|
if (entry.xss_alert) threat_badges.push('<span class="badge bg-danger me-1">XSS</span>');
|
||||||
|
if (entry.sql_alert) threat_badges.push('<span class="badge bg-danger me-1">SQL</span>');
|
||||||
|
if (entry.webshell_alert) threat_badges.push('<span class="badge bg-danger me-1">SHELL</span>');
|
||||||
|
if (entry.put_method) threat_badges.push('<span class="badge bg-danger me-1">PUT</span>');
|
||||||
|
if (entry.illegal_resource) threat_badges.push('<span class="badge bg-warning me-1">403</span>');
|
||||||
|
|
||||||
|
const threat_html = threat_badges.length > 0 ? `<div class="mb-2">${threat_badges.join('')}</div>` : '';
|
||||||
|
|
||||||
|
let row_class = '';
|
||||||
|
if (entry.has_threat) {
|
||||||
|
row_class = 'table-danger';
|
||||||
|
} else if (entry.status_code.startsWith('5')) {
|
||||||
|
row_class = 'table-danger';
|
||||||
|
} else if (entry.status_code.startsWith('4')) {
|
||||||
|
row_class = 'table-warning';
|
||||||
|
} else if (entry.status_code.startsWith('2')) {
|
||||||
|
row_class = 'table-light';
|
||||||
|
} else {
|
||||||
|
row_class = 'table-light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${row_class}" style="font-family: monospace; font-size: 11px;">
|
||||||
|
<td>
|
||||||
|
${threat_html}
|
||||||
|
<small style="color: #0066cc;">${escapeHtml(entry.timestamp)}</small><br>
|
||||||
|
<small style="color: #666;">${escapeHtml(entry.ip_address)}</small>
|
||||||
|
<strong style="color: #333;">${escapeHtml(entry.http_method)}</strong>
|
||||||
|
<code style="color: #333;">${escapeHtml(entry.requested_url)}</code>
|
||||||
|
<span class="badge bg-dark" style="color: white; margin-left: 5px;">${escapeHtml(entry.status_code)}</span>
|
||||||
|
<br>
|
||||||
|
<small style="color: #666;">${escapeHtml(entry.frontend)}~ ${escapeHtml(entry.backend)}</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update exclude UI
|
||||||
|
*/
|
||||||
|
function updateExcludeUI() {
|
||||||
|
if (excludePhrases.length > 0) {
|
||||||
|
const tags = excludePhrases.map((phrase, idx) => `
|
||||||
|
<span class="badge bg-warning text-dark me-2" style="cursor: pointer;" onclick="window.removeExcludePhrase(${idx})">
|
||||||
|
${escapeHtml(phrase)} <i class="bi bi-x"></i>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'small mt-2';
|
||||||
|
container.innerHTML = `<strong>Hiding:</strong> ${tags}`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('exclude_ui');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
container.id = 'exclude_ui';
|
||||||
|
excludeFilter.parentElement.parentElement.after(container);
|
||||||
|
} else {
|
||||||
|
const existing = document.getElementById('exclude_ui');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove exclude phrase
|
||||||
|
*/
|
||||||
|
window.removeExcludePhrase = function(idx) {
|
||||||
|
console.log(`[Logs] Remove exclude phrase at index ${idx}`);
|
||||||
|
excludePhrases.splice(idx, 1);
|
||||||
|
updateExcludeUI();
|
||||||
|
currentPage = 1;
|
||||||
|
loadLogsWithPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error
|
||||||
|
*/
|
||||||
|
function showError(msg) {
|
||||||
|
logsContainer.innerHTML = `<tr><td class="alert alert-danger mb-0">${escapeHtml(msg)}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||||
|
return (text || '').replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
console.log('[Logs] Initial load');
|
||||||
|
loadLogsWithPage();
|
||||||
|
});
|
||||||
|
|||||||
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,35 +1,47 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=root
|
|
||||||
loglevel=info
|
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
pidfile=/var/run/supervisord.pid
|
pidfile=/var/run/supervisord.pid
|
||||||
|
childlogdir=/var/log/supervisor
|
||||||
|
|
||||||
[program:haproxy]
|
[unix_http_server]
|
||||||
command=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg
|
file=/var/run/supervisor.sock
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stderr_logfile=/var/log/supervisor/haproxy.err.log
|
|
||||||
stdout_logfile=/var/log/haproxy.log
|
|
||||||
priority=100
|
|
||||||
stopasgroup=true
|
|
||||||
killasgroup=true
|
|
||||||
|
|
||||||
[program:flask_app]
|
[supervisorctl]
|
||||||
|
serverurl=unix:///var/run/supervisor.sock
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
# ===== FLASK APPLICATION =====
|
||||||
|
[program:flask]
|
||||||
command=python /app/app.py
|
command=python /app/app.py
|
||||||
directory=/app
|
directory=/app
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
stderr_logfile=/var/log/supervisor/flask_app.err.log
|
stdout_logfile=/var/log/supervisor/flask.log
|
||||||
stdout_logfile=/var/log/supervisor/flask_app.out.log
|
stderr_logfile=/var/log/supervisor/flask_error.log
|
||||||
priority=999
|
|
||||||
environment=FLASK_APP=/app/app.py,FLASK_ENV=production,PYTHONUNBUFFERED=1
|
|
||||||
startsecs=10
|
|
||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
priority=999
|
||||||
|
environment=PYTHONUNBUFFERED=1,FLASK_APP=app.py,FLASK_ENV=production
|
||||||
|
|
||||||
[unix_http_server]
|
# ===== HAPROXY =====
|
||||||
file=/var/run/supervisor.sock
|
[program:haproxy]
|
||||||
chmod=0700
|
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
|
||||||
|
|
||||||
[supervisorctl]
|
# ===== LOG ROTATION =====
|
||||||
serverurl=unix:///var/run/supervisor.sock
|
[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,12 +1,13 @@
|
|||||||
{% set active_page = active_page|default('') %}
|
{% set active_page = active_page|default('') %}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="pl" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}HAProxy Configurator{% endblock %}</title>
|
<title>{% block title %}HAProxy Configurator{% endblock %}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/edit.css') }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
|
||||||
@@ -14,46 +15,190 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.user-menu {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.menu-divider {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-body">
|
<body class="bg-body">
|
||||||
<header class="header1" id="header1">
|
<!-- NAVBAR -->
|
||||||
<div class="container d-flex align-items-center justify-content-between flex-wrap gap-2 py-2">
|
<header class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||||
<a href="{{ url_for('home') }}" class="d-flex align-items-center text-decoration-none logo text-reset">
|
<div class="container-fluid px-4">
|
||||||
<h3 class="m-0 d-flex align-items-center gap-2">
|
<!-- Logo -->
|
||||||
<i class="fas fa-globe"></i><span>HAProxy Configurator</span>
|
<a href="{% if session.get('user_id') %}{{ url_for('main.index') }}{% else %}{{ url_for('auth.login') }}{% endif %}" class="navbar-brand d-flex align-items-center gap-2">
|
||||||
</h3>
|
<i class="fas fa-globe"></i>
|
||||||
</a>
|
<span>HAProxy Manager</span>
|
||||||
<nav class="menu d-flex align-items-center gap-1 flex-wrap">
|
</a>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<!-- Toggle Button (Mobile) -->
|
||||||
<div class="container d-flex flex-wrap justify-content-between align-items-center py-3 small text-muted">
|
<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>© 2025 HAProxy Configurator</span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
<span class="d-flex align-items-center gap-2">
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
<!-- Navigation Menu -->
|
||||||
{% block scripts %}{% endblock %}
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
{% block page_js %}{% endblock %}
|
{% if session.get('user_id') %}
|
||||||
</body>
|
<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>
|
</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 %}
|
||||||
@@ -1,34 +1,83 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "" %}
|
|
||||||
{% block title %}HAProxy • Edit{% 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" aria-current="page">Edytor</li></ol></nav>{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="mb-3 text-muted">Edit HAProxy configuration</h4>
|
|
||||||
<form method="POST" novalidate>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="haproxy_config" class="form-label">Config</label>
|
|
||||||
<textarea class="form-control" name="haproxy_config" id="haproxy_config" rows="20">{{ config_content }}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-warning" id="save_check" name="save_check">
|
|
||||||
<i class="bi bi-search me-1"></i> Check & Save
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary" name="save_reload">
|
|
||||||
<i class="bi bi-arrow-repeat me-1"></i> Check & Restart
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% if check_output %}
|
|
||||||
<div class="alert alert-{{ check_level|default('success') }}" role="alert">
|
|
||||||
<pre class="mb-0">{{ check_output }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
{% set active_page = "edit" %}
|
||||||
|
|
||||||
|
{% block title %}HAProxy • Configuration Editor{% 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" aria-current="page">Edit Configuration</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- CodeMirror CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/material-darker.min.css">
|
||||||
|
|
||||||
|
{% if check_output %}
|
||||||
|
<div class="alert alert-{{ check_level|default('info') }} alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-{% if check_level == 'success' %}check-circle{% elif check_level == 'danger' %}exclamation-circle{% elif check_level == 'warning' %}exclamation-triangle{% else %}info-circle{% endif %} me-2"></i>
|
||||||
|
<strong>
|
||||||
|
{% if check_level == 'success' %}Configuration Saved
|
||||||
|
{% elif check_level == 'danger' %}Configuration Error
|
||||||
|
{% elif check_level == 'warning' %}Warning
|
||||||
|
{% else %}Information{% endif %}
|
||||||
|
</strong>
|
||||||
|
<div class="mt-2 small" style="font-family: monospace; background-color: rgba(0,0,0,0.1); padding: 10px; border-radius: 4px; max-height: 250px; overflow-y: auto; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word;">{{ check_output }}</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endif %}
|
||||||
{% block page_js %}
|
|
||||||
<script src="{{ url_for('static', filename='js/edit.js') }}"></script>
|
<!-- Editor Section -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0"><i class="bi bi-pencil-square me-2"></i>HAProxy Configuration Editor</h5>
|
||||||
|
</div>
|
||||||
|
<small class="text-white-50">Real-time editor with syntax highlighting</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body" style="padding: 0;">
|
||||||
|
<form method="post" id="edit_form">
|
||||||
|
<!-- Editor Container -->
|
||||||
|
<div style="border-bottom: 1px solid #dee2e6;">
|
||||||
|
<textarea id="haproxy_editor" name="haproxy_config" style="display: none;">{{ config_content }}</textarea>
|
||||||
|
<!-- Fallback textarea (hidden by default) -->
|
||||||
|
<textarea id="haproxy_config" name="haproxy_config" style="display: none; width: 100%; border: none; font-family: monospace; font-size: 13px; resize: none; padding: 12px; min-height: 500px; background: #1e1e1e; color: #e8e8e8;"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="p-3 bg-dark d-flex justify-content-between align-items-center flex-wrap gap-2" style="border-top: 1px solid #444;">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-success btn-sm" name="action" value="check">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Validate Configuration
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" name="action" value="save">
|
||||||
|
<i class="bi bi-save me-1"></i>Save & Restart HAProxy
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('main.index') }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" style="color: #aaa !important;">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<span id="line_col">Line 1, Col 1</span> |
|
||||||
|
<span id="char_count">0</span> characters
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CodeMirror JS -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/nginx/nginx.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Editor JS -->
|
||||||
|
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,220 +1,475 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% set active_page = "index" %}
|
{% set active_page = "index" %}
|
||||||
{% block title %}HAProxy • Index{% 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" aria-current="page">Konfiguracja</li></ol></nav>{% endblock %}
|
{% block title %}HAProxy • Configuration{% 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" aria-current="page">Add Configuration</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post" action="/" id="fe-be-form" novalidate>
|
|
||||||
<h5 class="mb-3"><i class="fas fa-globe me-2"></i>New frontend</h5>
|
|
||||||
{% if message %}
|
|
||||||
<div class="alert {% if 'already exists' in message %}alert-danger{% else %}alert-success{% endif %} alert-dismissible" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label class="form-label" for="frontend_name">Name</label>
|
<div class="card text-center shadow-sm">
|
||||||
<input type="text" class="form-control" name="frontend_name" id="frontend_name" required>
|
<div class="card-body">
|
||||||
</div>
|
<h5 class="card-title text-primary">{{ frontend_count|default(0) }}</h5>
|
||||||
<div class="col-md-4">
|
<p class="card-text"><i class="bi bi-diagram-2"></i> Frontends</p>
|
||||||
<label class="form-label" for="frontend_ip">IP</label>
|
|
||||||
<input type="text" class="form-control" name="frontend_ip" id="frontend_ip" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="frontend_port">Port</label>
|
|
||||||
<input type="number" class="form-control" name="frontend_port" id="frontend_port" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input type="checkbox" class="form-check-input" id="ssl_checkbox" name="ssl_checkbox">
|
|
||||||
<label class="form-check-label" for="ssl_checkbox"><i class="fas fa-lock me-2"></i>SSL cert</label>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1 d-none" id="ssl_fields">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label" for="ssl_cert_path">Certificate path (put in /ssl/)</label>
|
|
||||||
<input type="text" id="ssl_cert_path" class="form-control" name="ssl_cert_path">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 d-flex align-items-end">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="ssl_redirect_checkbox" name="ssl_redirect_checkbox">
|
|
||||||
<label class="form-check-label" for="ssl_redirect_checkbox"><i class="fas fa-arrow-circle-right me-2"></i>Redirect do HTTPS</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mt-1">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="lb_method">Metoda LB</label>
|
|
||||||
<select class="form-select" name="lb_method" id="lb_method">
|
|
||||||
<option value="roundrobin">Round Robin</option>
|
|
||||||
<option value="leastconn">Least Connections</option>
|
|
||||||
<option value="source">Source</option>
|
|
||||||
<option value="wrr">WRR</option>
|
|
||||||
<option value="wlc">WLC</option>
|
|
||||||
<option value="random">Random</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="protocol">Tryb</label>
|
|
||||||
<select class="form-select" name="protocol" id="protocol" required>
|
|
||||||
<option value="" disabled selected>--Select--</option>
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="http">HTTP</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input type="checkbox" class="form-check-input" name="add_dos" id="add_dos">
|
|
||||||
<label class="form-check-label" for="add_dos"><i class="fas fa-shield-alt me-2"></i>DOS protection</label>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1 d-none" id="dos_fields">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label" for="limit_requests">Limit (np. 20)</label>
|
|
||||||
<input type="text" class="form-control" name="limit_requests" id="limit_requests">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label" for="ban_duration">Ban (np. 15s)</label>
|
|
||||||
<input type="text" class="form-control" name="ban_duration" id="ban_duration">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mt-2 d-none http-only" id="http_extras">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="sql_injection_check" name="sql_injection_check">
|
|
||||||
<label class="form-check-label" for="sql_injection_check"><i class="fas fa-shield-alt me-2"></i>SQLi</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<div class="form-check">
|
</div>
|
||||||
<input type="checkbox" class="form-check-input" id="xss_check" name="xss_check">
|
<div class="col-md-3">
|
||||||
<label class="form-check-label" for="xss_check"><i class="fas fa-shield-alt me-2"></i>XSS</label>
|
<div class="card text-center shadow-sm">
|
||||||
</div>
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-success">{{ backend_count|default(0) }}</h5>
|
||||||
|
<p class="card-text"><i class="bi bi-hdd-rack"></i> Backends</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<div class="form-check">
|
</div>
|
||||||
<input type="checkbox" class="form-check-input" id="remote_uploads_check" name="remote_uploads_check">
|
<div class="col-md-3">
|
||||||
<label class="form-check-label" for="remote_uploads_check"><i class="fas fa-shield-alt me-2"></i>Remote uploads</label>
|
<div class="card text-center shadow-sm">
|
||||||
</div>
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-warning">{{ acl_count|default(0) }}</h5>
|
||||||
|
<p class="card-text"><i class="bi bi-shield"></i> ACLs</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<div class="form-check">
|
</div>
|
||||||
<input type="checkbox" class="form-check-input" id="webshells_check" name="webshells_check">
|
<div class="col-md-3">
|
||||||
<label class="form-check-label" for="webshells_check"><i class="fas fa-shield-alt me-2"></i>Webshells</label>
|
<div class="card text-center shadow-sm">
|
||||||
</div>
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-info">L7: {{ layer7_count|default(0) }} / L4: {{ layer4_count|default(0) }}</h5>
|
||||||
|
<p class="card-text"><i class="bi bi-layers"></i> Layers</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="forward_for_check" name="forward_for_check">
|
|
||||||
<label class="form-check-label" for="forward_for_check"><i class="fas fa-network-wired me-2"></i>forwardfor</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input type="checkbox" class="form-check-input" name="add_acl" id="add_acl">
|
|
||||||
<label class="form-check-label" for="add_acl"><i class="fas fa-user-lock me-2"></i>ACL for frontend</label>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1 d-none" id="acl_fields">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="acl">ACL</label>
|
|
||||||
<input type="text" class="form-control" name="acl" id="acl" placeholder="acl_name">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="acl_action">Action</label>
|
|
||||||
<input type="text" class="form-control" name="acl_action" id="acl_action" placeholder="hdr(host) -i test.com">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="backend_name_acl">Backend</label>
|
|
||||||
<input type="text" class="form-control" name="backend_name_acl" id="backend_name_acl" placeholder="somebackend">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-3 http-only d-none" id="forbidden_acl_container">
|
|
||||||
<input type="checkbox" class="form-check-input" name="add_acl_path" id="add_acl_path">
|
|
||||||
<label class="form-check-label" for="add_acl_path"><i class="fas fa-ban me-2"></i>Block path</label>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1 d-none" id="forbidden_fields">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="forbidden_name">ACL name</label>
|
|
||||||
<input type="text" class="form-control" name="forbidden_name" id="forbidden_name">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="allowed_ip">Allowed IP</label>
|
|
||||||
<input type="text" class="form-control" name="allowed_ip" id="allowed_ip">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="forbidden_path">Path (ex. /admin)</label>
|
|
||||||
<input type="text" class="form-control" name="forbidden_path" id="forbidden_path">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-check mt-3 http-only d-none" id="path_based_container">
|
|
||||||
<input type="checkbox" class="form-check-input" name="add_path_based" id="add_path_based">
|
|
||||||
<label class="form-check-label" for="add_path_based"><i class="fas fa-arrow-circle-right me-2"></i>Path-based redirect</label>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-1 d-none" id="base_redirect_fields">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="redirect_domain_name">Domena docelowa</label>
|
|
||||||
<input type="text" class="form-control" name="redirect_domain_name" id="redirect_domain_name" placeholder="test2.com:8888">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="root_redirect">Root path</label>
|
|
||||||
<input type="text" class="form-control" name="root_redirect" id="root_redirect" placeholder="/">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="redirect_to">Redirect to</label>
|
|
||||||
<input type="text" class="form-control" name="redirect_to" id="redirect_to" placeholder="/test">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4">
|
|
||||||
|
|
||||||
<h5 class="mb-3"><i class="fas fa-sitemap me-2"></i>Backend pool</h5>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label" for="backend_name">Backend name</label>
|
|
||||||
<input type="text" class="form-control" name="backend_name" id="backend_name" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="backend_servers_container" class="mt-3">
|
|
||||||
<div class="row g-3 backend-server-row">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="name1">Server name</label>
|
|
||||||
<input type="text" id="name1" class="form-control" name="backend_server_names[]" placeholder="server1" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="ip1">IP</label>
|
|
||||||
<input type="text" id="ip1" class="form-control" name="backend_server_ips[]" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="port1">Port</label>
|
|
||||||
<input type="number" id="port1" class="form-control" name="backend_server_ports[]" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label" for="maxconn1">MaxConn</label>
|
|
||||||
<input type="number" id="maxconn1" class="form-control" name="backend_server_maxconns[]">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-3">
|
|
||||||
<button type="button" class="btn btn-secondary" id="add_backend_btn"><i class="bi bi-plus-lg me-1"></i>Add backend</button>
|
|
||||||
<button type="submit" class="btn btn-success" id="success_btn"><i class="bi bi-check2-circle me-1"></i>Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
{% block page_js %}
|
{% if message %}
|
||||||
|
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-{% if message_type == 'success' %}check-circle{% elif message_type == 'danger' %}exclamation-circle{% elif message_type == 'warning' %}exclamation-triangle{% else %}info-circle{% endif %} me-2"></i>
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="needs-validation">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add New Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<!-- FRONTEND SECTION -->
|
||||||
|
<h6 class="text-primary mb-3"><i class="bi bi-hdd-network me-2"></i>Frontend Configuration</h6>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="frontend_ip" class="form-label">Listener IP</label>
|
||||||
|
<input type="text" class="form-control" id="frontend_ip" name="frontend_ip"
|
||||||
|
placeholder="0.0.0.0" value="0.0.0.0" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="frontend_port" class="form-label">Listener Port</label>
|
||||||
|
<input type="number" class="form-control" id="frontend_port" name="frontend_port"
|
||||||
|
placeholder="443" value="443" min="1" max="65535" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="frontend_hostname" class="form-label">Frontend Hostname</label>
|
||||||
|
<input type="text" class="form-control" id="frontend_hostname" name="frontend_hostname"
|
||||||
|
placeholder="e.g. host.domain.com" required>
|
||||||
|
<small class="text-muted d-block mt-1">Frontend name will be generated automatically</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="protocol" class="form-label">Protocol</label>
|
||||||
|
<select class="form-select" id="protocol" name="protocol" required>
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="lb_method" class="form-label">Load Balancing Method</label>
|
||||||
|
<select class="form-select" id="lb_method" name="lb_method" required>
|
||||||
|
<option value="no-lb">No Load Balancing (single host)</option>
|
||||||
|
<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>
|
||||||
|
<option value="static-rr">Static Round Robin (WRR)</option>
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSL Section -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="ssl_checkbox" name="ssl_checkbox">
|
||||||
|
<label class="form-check-label" for="ssl_checkbox">
|
||||||
|
<i class="bi bi-lock me-1"></i>Use SSL (HTTPS)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3 d-none" id="ssl_fields">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="ssl_cert_path" class="form-label">SSL Certificate Path</label>
|
||||||
|
<input type="text" class="form-control" id="ssl_cert_path" name="ssl_cert_path"
|
||||||
|
value="/app/ssl/haproxy-configurator.pem">
|
||||||
|
<small class="text-muted">Full path to .pem file</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="ssl_redirect_checkbox"
|
||||||
|
name="ssl_redirect_checkbox">
|
||||||
|
<label class="form-check-label" for="ssl_redirect_checkbox">
|
||||||
|
Redirect HTTP to HTTPS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTTP to HTTPS Redirect -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="backend_ssl_redirect"
|
||||||
|
name="backend_ssl_redirect">
|
||||||
|
<label class="form-check-label" for="backend_ssl_redirect">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Add HTTP Redirect to HTTPS
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block">Creates additional frontend to redirect HTTP traffic to HTTPS</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3 d-none" id="backend_ssl_fields">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="ssl_redirect_backend_name" class="form-label">Redirect Backend Name</label>
|
||||||
|
<input type="text" class="form-control" id="ssl_redirect_backend_name"
|
||||||
|
name="ssl_redirect_backend_name" placeholder="e.g. redirect">
|
||||||
|
<small class="text-muted">Name for the redirect backend</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="ssl_redirect_port" class="form-label">HTTP Redirect Port</label>
|
||||||
|
<input type="number" class="form-control" id="ssl_redirect_port"
|
||||||
|
name="ssl_redirect_port" value="80" min="1" max="65535">
|
||||||
|
<small class="text-muted">Default: 80 (leave empty for standard)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- BACKEND SECTION -->
|
||||||
|
<h6 class="text-primary mb-3"><i class="bi bi-hdd-rack me-2"></i>Backend Configuration</h6>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="backend_name" class="form-label">Backend Name</label>
|
||||||
|
<input type="text" class="form-control" id="backend_name" name="backend_name"
|
||||||
|
placeholder="e.g. be_web" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backend servers -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Backend Servers</label>
|
||||||
|
<div id="backend_servers_container">
|
||||||
|
<div class="row g-3 backend-server-row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" class="form-control" name="backend_server_names[]"
|
||||||
|
placeholder="server1" value="server1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="text" class="form-control" name="backend_server_ips[]"
|
||||||
|
placeholder="192.168.1.10" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" class="form-control" name="backend_server_ports[]"
|
||||||
|
placeholder="80" min="1" max="65535" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="number" class="form-control" name="backend_server_maxconns[]"
|
||||||
|
placeholder="100">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm w-100 remove-server" style="visibility: hidden;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm mt-2" id="add_backend_btn">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health Check -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="health_check" name="health_check">
|
||||||
|
<label class="form-check-label" for="health_check">
|
||||||
|
<i class="bi bi-heart-pulse me-1"></i>Enable Health Check
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 d-none" id="health_check_fields">
|
||||||
|
<label for="health_check_link" class="form-label">Health Check Path</label>
|
||||||
|
<input type="text" class="form-control" id="health_check_link" name="health_check_link"
|
||||||
|
value="/" placeholder="/">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3" style="display: none;" id="tcp_health_check">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="health_check2" name="health_check2">
|
||||||
|
<label class="form-check-label" for="health_check2">
|
||||||
|
<i class="bi bi-heart-pulse me-1"></i>Enable TCP Health Check
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Session -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sticky_session" name="sticky_session">
|
||||||
|
<label class="form-check-label" for="sticky_session">
|
||||||
|
<i class="bi bi-pin-angle me-1"></i>Sticky Session
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 d-none" id="sticky_fields">
|
||||||
|
<select class="form-select" id="sticky_session_type" name="sticky_session_type">
|
||||||
|
<option value="cookie">Cookie-based</option>
|
||||||
|
<option value="source">Source IP-based</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- HEADERS & SECURITY SECTION -->
|
||||||
|
<h6 class="text-primary mb-3"><i class="bi bi-shield-lock me-2"></i>Headers & Security</h6>
|
||||||
|
|
||||||
|
<!-- Custom Headers -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="add_header" name="add_header">
|
||||||
|
<label class="form-check-label" for="add_header">
|
||||||
|
<i class="bi bi-tag me-1"></i>Add Custom Header
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-none" id="header_fields">
|
||||||
|
<input type="text" class="form-control" id="header_name" name="header_name"
|
||||||
|
placeholder="e.g. X-Custom-Header">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-none" id="header_fields">
|
||||||
|
<input type="text" class="form-control" id="header_value" name="header_value"
|
||||||
|
placeholder="e.g. custom-value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Header Removal -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="del_server_header"
|
||||||
|
name="del_server_header">
|
||||||
|
<label class="form-check-label" for="del_server_header">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>Hide Server Header
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block">Adds: <code>http-response del-header Server</code></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forward For -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="forward_for_check"
|
||||||
|
name="forward_for_check" checked>
|
||||||
|
<label class="form-check-label" for="forward_for_check">
|
||||||
|
<i class="bi bi-arrow-right me-1"></i>Forward For (X-Forwarded-For)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- PROTECTION SECTION -->
|
||||||
|
<h6 class="text-primary mb-3"><i class="bi bi-bug me-2"></i>Protection</h6>
|
||||||
|
|
||||||
|
<!-- DOS Protection -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="add_dos" name="add_dos">
|
||||||
|
<label class="form-check-label" for="add_dos">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>DOS/DDoS Protection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-none" id="dos_fields">
|
||||||
|
<label for="ban_duration" class="form-label">Ban Duration</label>
|
||||||
|
<input type="text" class="form-control" id="ban_duration" name="ban_duration"
|
||||||
|
value="30m" placeholder="30m">
|
||||||
|
<small class="text-muted">e.g. 30m, 1h, 24h</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-none" id="dos_fields">
|
||||||
|
<label for="limit_requests" class="form-label">Request Limit (per min)</label>
|
||||||
|
<input type="number" class="form-control" id="limit_requests" name="limit_requests"
|
||||||
|
value="100" min="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SQL Injection -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sql_injection_check"
|
||||||
|
name="sql_injection_check">
|
||||||
|
<label class="form-check-label" for="sql_injection_check">
|
||||||
|
<i class="bi bi-database-exclamation me-1"></i>SQL Injection Protection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- XSS -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="xss_check" name="xss_check">
|
||||||
|
<label class="form-check-label" for="xss_check">
|
||||||
|
<i class="bi bi-code-slash me-1"></i>XSS Protection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote Uploads -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remote_uploads_check"
|
||||||
|
name="remote_uploads_check">
|
||||||
|
<label class="form-check-label" for="remote_uploads_check">
|
||||||
|
<i class="bi bi-cloud-upload me-1"></i>Block Remote Uploads
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webshells -->
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="webshells_check"
|
||||||
|
name="webshells_check">
|
||||||
|
<label class="form-check-label" for="webshells_check">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>Block Webshells
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- CUSTOM ACL SECTION -->
|
||||||
|
<h6 class="text-primary mb-3"><i class="bi bi-shuffle me-2"></i>Custom ACL Rules (Advanced)</h6>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3 http-only">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="add_custom_acl" name="add_custom_acl">
|
||||||
|
<label class="form-check-label" for="add_custom_acl">
|
||||||
|
<i class="bi bi-sliders me-1"></i>Add Custom ACL Rule
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block">Create additional routing or blocking rules</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom ACL Fields -->
|
||||||
|
<div class="row g-3 mb-3 http-only d-none" id="custom_acl_fields">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="custom_acl_name" class="form-label">ACL Name</label>
|
||||||
|
<input type="text" class="form-control" id="custom_acl_name" name="custom_acl_name"
|
||||||
|
placeholder="e.g. is_admin_path">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="custom_acl_type" class="form-label">Rule Type</label>
|
||||||
|
<select class="form-select" id="custom_acl_type" name="custom_acl_type">
|
||||||
|
<option value="path_beg">Path Begins With</option>
|
||||||
|
<option value="path_end">Path Ends With</option>
|
||||||
|
<option value="path_sub">Path Contains</option>
|
||||||
|
<option value="hdr">Header Contains</option>
|
||||||
|
<option value="src">Source IP</option>
|
||||||
|
<option value="method">HTTP Method</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="custom_acl_value" class="form-label">Rule Value</label>
|
||||||
|
<input type="text" class="form-control" id="custom_acl_value" name="custom_acl_value"
|
||||||
|
placeholder="e.g. /admin, api, 192.168.1.0/24">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="custom_acl_action" class="form-label">Action</label>
|
||||||
|
<select class="form-select" id="custom_acl_action" name="custom_acl_action">
|
||||||
|
<option value="route">Route to Backend</option>
|
||||||
|
<option value="deny">Block (Deny)</option>
|
||||||
|
<option value="redirect">Redirect</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 d-none" id="acl_backend_select">
|
||||||
|
<label for="custom_acl_backend" class="form-label">Target Backend</label>
|
||||||
|
<input type="text" class="form-control" id="custom_acl_backend" name="custom_acl_backend"
|
||||||
|
placeholder="e.g. be_admin">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 d-none" id="acl_redirect_select">
|
||||||
|
<label for="custom_acl_redirect_url" class="form-label">Redirect URL</label>
|
||||||
|
<input type="text" class="form-control" id="custom_acl_redirect_url" name="custom_acl_redirect_url"
|
||||||
|
placeholder="e.g. https://example.com/new-path">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- SUBMIT BUTTON -->
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/form.js') }}"></script>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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>
|
||||||
@@ -1,50 +1,104 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "" %}
|
|
||||||
|
{% set active_page = "logs" %}
|
||||||
|
|
||||||
{% block title %}HAProxy • Logs{% endblock %}
|
{% block title %}HAProxy • Logs{% 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" aria-current="page">Logi</li></ol></nav>{% endblock %}
|
|
||||||
{% block content %}
|
{% block breadcrumb %}
|
||||||
<h3 class="mb-4" id="status_header">Status 403 Forbidden</h3>
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
{% if entries %}
|
<ol class="breadcrumb mb-0">
|
||||||
<div class="vstack gap-3">
|
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li>
|
||||||
{% for entry in entries %}
|
<li class="breadcrumb-item active" aria-current="page">Logs</li>
|
||||||
<div class="card">
|
</ol>
|
||||||
<div class="card-body">
|
</nav>
|
||||||
<div class="row g-3">
|
{% endblock %}
|
||||||
<div class="col-md-6">
|
|
||||||
<div><strong>Czas:</strong> {{ entry['timestamp'] }}</div>
|
{% block content %}
|
||||||
<div><strong>IP:</strong> {{ entry['ip_address'] }}</div>
|
|
||||||
<div><strong>Metoda:</strong> {{ entry['http_method'] }}</div>
|
<div class="card shadow-sm mb-4">
|
||||||
<div><strong>URL:</strong> {{ entry['requested_url'] }}</div>
|
<div class="card-header bg-primary text-white">
|
||||||
<div><strong>Status:</strong> <span class="badge bg-danger">403</span></div>
|
<h5 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i>HAProxy Logs</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
{% if entry['xss_alert'] %}
|
<div class="card-body">
|
||||||
<p class="mb-1"><button class="btn btn-sm btn-outline-danger" data-bs-toggle="collapse" data-bs-target="#xssCollapse{{ loop.index }}"><i class="bi bi-bug"></i> XSS alert</button></p>
|
{% if error_message %}
|
||||||
<div id="xssCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-danger">{{ entry['xss_alert'] }}</pre></div>
|
<div class="alert alert-warning">
|
||||||
{% endif %}
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error_message }}
|
||||||
{% if entry['sql_alert'] %}
|
</div>
|
||||||
<p class="mb-1"><button class="btn btn-sm btn-outline-warning" data-bs-toggle="collapse" data-bs-target="#sqlCollapse{{ loop.index }}"><i class="bi bi-database-exclamation"></i> SQLi alert</button></p>
|
{% endif %}
|
||||||
<div id="sqlCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-warning">{{ entry['sql_alert'] }}</pre></div>
|
|
||||||
{% endif %}
|
<!-- Controls Row -->
|
||||||
{% if entry['put_method'] %}
|
<div class="row g-2 mb-3">
|
||||||
<p class="mb-1"><button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#putCollapse{{ loop.index }}"><i class="bi bi-upload"></i> PUT alert</button></p>
|
<div class="col-md-3">
|
||||||
<div id="putCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-info">{{ entry['put_method'] }}</pre></div>
|
<div class="input-group input-group-sm">
|
||||||
{% endif %}
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
{% if entry['illegal_resource'] %}
|
<input type="text" class="form-control" id="search_filter" placeholder="Search logs...">
|
||||||
<p class="mb-1"><button class="btn btn-sm btn-outline-light" data-bs-toggle="collapse" data-bs-target="#illegalCollapse{{ loop.index }}"><i class="bi bi-shield-x"></i> Nielegalny zasób</button></p>
|
</div>
|
||||||
<div id="illegalCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-light">{{ entry['illegal_resource'] }}</pre></div>
|
</div>
|
||||||
{% endif %}
|
<div class="col-md-2">
|
||||||
{% if entry['webshell_alert'] %}
|
<button class="btn btn-sm btn-outline-secondary w-100" id="clear_filter_btn" title="Clear search">
|
||||||
<p class="mb-1"><button class="btn btn-sm btn-outline-danger" data-bs-toggle="collapse" data-bs-target="#webshellCollapse{{ loop.index }}"><i class="bi bi-file-earmark-code"></i> WebShell alert</button></p>
|
<i class="bi bi-x-circle me-1"></i>Clear
|
||||||
<div id="webshellCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-danger">{{ entry['webshell_alert'] }}</pre></div>
|
</button>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="col-md-2">
|
||||||
</div>
|
<select class="form-select form-select-sm" id="logs_per_page">
|
||||||
</div>
|
<option value="25" selected>25 per page</option>
|
||||||
</div>
|
<option value="50">50 per page</option>
|
||||||
{% endfor %}
|
<option value="100">100 per page</option>
|
||||||
</div>
|
<option value="200">200 per page</option>
|
||||||
{% else %}
|
</select>
|
||||||
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>No data.</div>
|
</div>
|
||||||
{% endif %}
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-sm btn-primary w-100" id="refresh_logs_btn">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text"><i class="bi bi-funnel"></i></span>
|
||||||
|
<input type="text" class="form-control" id="exclude_filter" placeholder="Hide phrase (e.g. /stats)">
|
||||||
|
<button class="btn btn-outline-warning btn-sm" id="exclude_btn" type="button">Hide</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Total:</strong> <span id="total_count">{{ total_logs|default(0) }}</span> logs |
|
||||||
|
<strong>Loaded:</strong> <span id="loaded_count">{{ loaded_count|default(0) }}</span> |
|
||||||
|
<strong>Displayed:</strong> <span id="match_count">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Container (Dark Theme) -->
|
||||||
|
<div id="logs_container_wrapper" style="max-height: 650px; overflow-y: auto; border: 1px solid #444; border-radius: 4px; background: #0d1117;">
|
||||||
|
<table class="table table-sm table-dark mb-0" id="logs_table">
|
||||||
|
<tbody id="logs_container">
|
||||||
|
<tr><td class="text-center text-muted py-4">Loading logs...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
Page <span id="current_page">1</span> / <span id="total_pages">1</span>
|
||||||
|
</small>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button class="btn btn-outline-primary" id="prev_btn" disabled>
|
||||||
|
<i class="bi bi-chevron-left"></i> Prev
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary" id="next_btn">
|
||||||
|
Next <i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" id="load_all_btn">
|
||||||
|
<i class="bi bi-download"></i> All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/logs.js') }}"></script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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
|
||||||
@@ -2,36 +2,88 @@ import os
|
|||||||
|
|
||||||
HAPROXY_CFG = '/etc/haproxy/haproxy.cfg'
|
HAPROXY_CFG = '/etc/haproxy/haproxy.cfg'
|
||||||
|
|
||||||
def is_frontend_exist(frontend_name, frontend_ip, frontend_port):
|
def sanitize_name(name):
|
||||||
"""Check if frontend with given name, IP and port already exists"""
|
"""Convert hostname/name to valid ACL name"""
|
||||||
|
return name.replace('.', '_').replace('-', '_').replace('/', '_').replace(':', '_')
|
||||||
|
|
||||||
|
def frontend_exists_at_port(frontend_ip, frontend_port):
|
||||||
|
"""Check if frontend already exists at specific port"""
|
||||||
|
if not os.path.exists(HAPROXY_CFG):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(HAPROXY_CFG, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
lines = content.split('\n')
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip().startswith('frontend'):
|
||||||
|
for j in range(i+1, min(i+10, len(lines))):
|
||||||
|
if lines[j].strip().startswith('bind'):
|
||||||
|
bind_info = lines[j].strip().split(' ', 1)[1]
|
||||||
|
bind_part = bind_info.split(' ssl ')[0].strip()
|
||||||
|
if f"{frontend_ip}:{frontend_port}" in bind_part:
|
||||||
|
return line.strip().split(' ', 1)[1] # Zwróć nazwę frontendu
|
||||||
|
elif lines[j].strip().startswith('frontend') or lines[j].strip().startswith('backend'):
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[HAPROXY_CONFIG] Error: {e}", flush=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_acl_to_frontend(frontend_name, acl_name, hostname, backend_name):
|
||||||
if not os.path.exists(HAPROXY_CFG):
|
if not os.path.exists(HAPROXY_CFG):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(HAPROXY_CFG, 'r') as haproxy_cfg:
|
with open(HAPROXY_CFG, 'r') as f:
|
||||||
frontend_found = False
|
lines = f.readlines()
|
||||||
for line in haproxy_cfg:
|
|
||||||
if line.strip().startswith('frontend'):
|
frontend_idx = -1
|
||||||
_, existing_frontend_name = line.strip().split(' ', 1)
|
for i, line in enumerate(lines):
|
||||||
if existing_frontend_name.strip() == frontend_name:
|
if 'frontend' in line and frontend_name in line:
|
||||||
frontend_found = True
|
frontend_idx = i
|
||||||
else:
|
break
|
||||||
frontend_found = False
|
|
||||||
elif frontend_found and line.strip().startswith('bind'):
|
if frontend_idx == -1:
|
||||||
_, bind_info = line.strip().split(' ', 1)
|
print(f"[HAPROXY_CONFIG] Frontend '{frontend_name}' not found", flush=True)
|
||||||
existing_ip, existing_port = bind_info.split(':', 1)
|
return False
|
||||||
if existing_ip.strip() == frontend_ip and existing_port.strip() == frontend_port:
|
|
||||||
return True
|
for line in lines[frontend_idx:]:
|
||||||
|
if acl_name in line and 'acl' in line:
|
||||||
|
print(f"[HAPROXY_CONFIG] ACL '{acl_name}' already exists", flush=True)
|
||||||
|
return True
|
||||||
|
if line.strip().startswith('backend'):
|
||||||
|
break
|
||||||
|
|
||||||
|
insert_idx = frontend_idx + 1
|
||||||
|
for i in range(frontend_idx + 1, len(lines)):
|
||||||
|
if lines[i].strip().startswith('backend') or lines[i].strip().startswith('frontend'):
|
||||||
|
insert_idx = i
|
||||||
|
break
|
||||||
|
if 'use_backend' in lines[i] or 'default_backend' in lines[i]:
|
||||||
|
insert_idx = i + 1
|
||||||
|
|
||||||
|
# Wstaw ACL i use_backend
|
||||||
|
acl_line = f" acl {acl_name} hdr(host) -i {hostname}\n"
|
||||||
|
use_backend_line = f" use_backend {backend_name} if {acl_name}\n"
|
||||||
|
|
||||||
|
lines.insert(insert_idx, use_backend_line)
|
||||||
|
lines.insert(insert_idx, acl_line)
|
||||||
|
|
||||||
|
with open(HAPROXY_CFG, 'w') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
print(f"[HAPROXY_CONFIG] ACL '{acl_name}' added to frontend '{frontend_name}'", flush=True)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HAPROXY_CONFIG] Error checking frontend: {e}", flush=True)
|
print(f"[HAPROXY_CONFIG] Error adding ACL: {e}", flush=True)
|
||||||
|
return False
|
||||||
return False
|
|
||||||
|
|
||||||
def is_backend_exist(backend_name):
|
def is_backend_exist(backend_name):
|
||||||
"""Check if backend with given name already exists"""
|
|
||||||
if not os.path.exists(HAPROXY_CFG):
|
if not os.path.exists(HAPROXY_CFG):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(HAPROXY_CFG, 'r') as haproxy_cfg:
|
with open(HAPROXY_CFG, 'r') as haproxy_cfg:
|
||||||
for line in haproxy_cfg:
|
for line in haproxy_cfg:
|
||||||
@@ -42,25 +94,24 @@ def is_backend_exist(backend_name):
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HAPROXY_CONFIG] Error checking backend: {e}", flush=True)
|
print(f"[HAPROXY_CONFIG] Error checking backend: {e}", flush=True)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def count_frontends_and_backends():
|
def count_frontends_and_backends():
|
||||||
"""Count frontends, backends, ACLs and layer types"""
|
|
||||||
if not os.path.exists(HAPROXY_CFG):
|
if not os.path.exists(HAPROXY_CFG):
|
||||||
return 0, 0, 0, 0, 0
|
return 0, 0, 0, 0, 0
|
||||||
|
|
||||||
frontend_count = 0
|
frontend_count = 0
|
||||||
backend_count = 0
|
backend_count = 0
|
||||||
acl_count = 0
|
acl_count = 0
|
||||||
layer7_count = 0
|
layer7_count = 0
|
||||||
layer4_count = 0
|
layer4_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(HAPROXY_CFG, 'r') as haproxy_cfg:
|
with open(HAPROXY_CFG, 'r') as haproxy_cfg:
|
||||||
content = haproxy_cfg.read()
|
content = haproxy_cfg.read()
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line_stripped = line.strip()
|
line_stripped = line.strip()
|
||||||
if line_stripped.startswith('frontend'):
|
if line_stripped.startswith('frontend'):
|
||||||
@@ -75,100 +126,259 @@ def count_frontends_and_backends():
|
|||||||
acl_count += 1
|
acl_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HAPROXY_CONFIG] Error counting: {e}", flush=True)
|
print(f"[HAPROXY_CONFIG] Error counting: {e}", flush=True)
|
||||||
|
|
||||||
return frontend_count, backend_count, acl_count, layer7_count, layer4_count
|
return frontend_count, backend_count, acl_count, layer7_count, layer4_count
|
||||||
|
|
||||||
def update_haproxy_config(frontend_name, frontend_ip, frontend_port, lb_method, protocol, backend_name,
|
def update_haproxy_config(frontend_name, frontend_ip, frontend_port, lb_method, protocol, backend_name,
|
||||||
backend_servers, health_check, health_check_tcp, health_check_link, sticky_session,
|
backend_servers, health_check, health_check_tcp, health_check_link, sticky_session,
|
||||||
add_header, header_name, header_value, sticky_session_type, is_acl, acl_name,
|
add_header, header_name, header_value, sticky_session_type, is_acl, acl_name,
|
||||||
acl_action, acl_backend_name, use_ssl, ssl_cert_path, https_redirect, is_dos,
|
acl_action, acl_backend_name, use_ssl, ssl_cert_path, https_redirect, is_dos,
|
||||||
ban_duration, limit_requests, forward_for, is_forbidden_path, forbidden_name,
|
ban_duration, limit_requests, forward_for, is_forbidden_path, forbidden_name,
|
||||||
allowed_ip, forbidden_path, sql_injection_check, is_xss, is_remote_upload,
|
allowed_ip, forbidden_path, sql_injection_check, is_xss, is_remote_upload,
|
||||||
add_path_based, redirect_domain_name, root_redirect, redirect_to, is_webshells):
|
add_path_based, redirect_domain_name, root_redirect, redirect_to, is_webshells,
|
||||||
|
del_server_header=False, backend_ssl_redirect=False, ssl_redirect_backend_name='',
|
||||||
# Ensure directory exists
|
ssl_redirect_port='80', frontend_hostname='', add_custom_acl=False,
|
||||||
|
custom_acl_name='', custom_acl_type='path_beg', custom_acl_value='',
|
||||||
|
custom_acl_action='route', custom_acl_backend='', custom_acl_redirect_url=''):
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(HAPROXY_CFG), exist_ok=True)
|
os.makedirs(os.path.dirname(HAPROXY_CFG), exist_ok=True)
|
||||||
|
|
||||||
if is_backend_exist(backend_name):
|
unique_backend_name = f"{backend_name}_{sanitize_name(frontend_hostname)}" if frontend_hostname else backend_name
|
||||||
return f"Backend {backend_name} already exists. Cannot add duplicate."
|
|
||||||
|
if is_backend_exist(unique_backend_name):
|
||||||
|
return f"Backend {unique_backend_name} already exists. Cannot add duplicate."
|
||||||
|
|
||||||
|
is_no_lb = lb_method == 'no-lb'
|
||||||
|
if is_no_lb and len(backend_servers) > 1:
|
||||||
|
backend_servers = backend_servers[:1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# ===== CHECK IF FRONTEND EXISTS AT PORT =====
|
||||||
|
existing_frontend = frontend_exists_at_port(frontend_ip, frontend_port)
|
||||||
|
|
||||||
|
if existing_frontend:
|
||||||
|
print(f"[HAPROXY] Found existing frontend '{existing_frontend}' at {frontend_ip}:{frontend_port}", flush=True)
|
||||||
|
|
||||||
|
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
|
||||||
|
# ===== BACKEND =====
|
||||||
|
haproxy_cfg.write(f"\nbackend {unique_backend_name}\n")
|
||||||
|
|
||||||
|
if not is_no_lb:
|
||||||
|
haproxy_cfg.write(f" balance {lb_method}\n")
|
||||||
|
|
||||||
|
if sticky_session and not is_no_lb:
|
||||||
|
if sticky_session_type == "cookie":
|
||||||
|
haproxy_cfg.write(f" cookie SERVERID insert indirect nocache\n")
|
||||||
|
elif sticky_session_type == "source":
|
||||||
|
haproxy_cfg.write(f" stick-table type ip size 200k expire 30m\n")
|
||||||
|
haproxy_cfg.write(f" stick on src\n")
|
||||||
|
|
||||||
|
if health_check and protocol == 'http':
|
||||||
|
haproxy_cfg.write(f" option httpchk GET {health_check_link}\n")
|
||||||
|
elif health_check_tcp and protocol == 'tcp':
|
||||||
|
haproxy_cfg.write(f" option tcp-check\n")
|
||||||
|
|
||||||
|
if add_header:
|
||||||
|
haproxy_cfg.write(f" http-response add-header {header_name} {header_value}\n")
|
||||||
|
|
||||||
|
if del_server_header:
|
||||||
|
haproxy_cfg.write(f" http-response del-header Server\n")
|
||||||
|
|
||||||
|
if forward_for:
|
||||||
|
haproxy_cfg.write(f" option forwardfor\n")
|
||||||
|
|
||||||
|
# Add servers
|
||||||
|
for server_name, server_ip, server_port, maxconn in backend_servers:
|
||||||
|
maxconn_str = f" maxconn {maxconn}" if maxconn else ""
|
||||||
|
|
||||||
|
if health_check and protocol == 'http':
|
||||||
|
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str} check\n")
|
||||||
|
else:
|
||||||
|
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str}\n")
|
||||||
|
|
||||||
|
acl_name_sanitized = f"is_{sanitize_name(frontend_hostname)}" if frontend_hostname else f"is_{unique_backend_name}"
|
||||||
|
add_acl_to_frontend(existing_frontend, acl_name_sanitized, frontend_hostname or 'localhost', unique_backend_name)
|
||||||
|
|
||||||
|
# ===== REDIRECT HTTP→HTTPS (jeśli zaznaczony) =====
|
||||||
|
if backend_ssl_redirect and ssl_redirect_backend_name:
|
||||||
|
unique_redirect_backend_name = f"{ssl_redirect_backend_name}_redirect_{sanitize_name(frontend_hostname)}" if frontend_hostname else f"{ssl_redirect_backend_name}_redirect"
|
||||||
|
|
||||||
|
existing_http_frontend = frontend_exists_at_port(frontend_ip, ssl_redirect_port)
|
||||||
|
|
||||||
|
if existing_http_frontend:
|
||||||
|
print(f"[HAPROXY] Adding redirect ACL to existing HTTP frontend '{existing_http_frontend}'", flush=True)
|
||||||
|
|
||||||
|
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
|
||||||
|
haproxy_cfg.write(f"\nbackend {unique_redirect_backend_name}\n")
|
||||||
|
haproxy_cfg.write(f" mode http\n")
|
||||||
|
haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n")
|
||||||
|
|
||||||
|
if frontend_hostname:
|
||||||
|
acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect"
|
||||||
|
add_acl_to_frontend(existing_http_frontend, acl_name_redirect, frontend_hostname, unique_redirect_backend_name)
|
||||||
|
else:
|
||||||
|
print(f"[HAPROXY] Creating new HTTP redirect frontend at {frontend_ip}:{ssl_redirect_port}", flush=True)
|
||||||
|
|
||||||
|
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
|
||||||
|
generic_http_redirect_name = f"http_redirect_frontend"
|
||||||
|
|
||||||
|
haproxy_cfg.write(f"\nfrontend {generic_http_redirect_name}\n")
|
||||||
|
haproxy_cfg.write(f" bind {frontend_ip}:{ssl_redirect_port}\n")
|
||||||
|
haproxy_cfg.write(f" mode http\n")
|
||||||
|
|
||||||
|
if frontend_hostname:
|
||||||
|
acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect"
|
||||||
|
haproxy_cfg.write(f" acl {acl_name_redirect} hdr(host) -i {frontend_hostname}\n")
|
||||||
|
haproxy_cfg.write(f" use_backend {unique_redirect_backend_name} if {acl_name_redirect}\n")
|
||||||
|
else:
|
||||||
|
haproxy_cfg.write(f" default_backend {unique_redirect_backend_name}\n")
|
||||||
|
|
||||||
|
# Redirect backend
|
||||||
|
haproxy_cfg.write(f"\nbackend {unique_redirect_backend_name}\n")
|
||||||
|
haproxy_cfg.write(f" mode http\n")
|
||||||
|
haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n")
|
||||||
|
|
||||||
|
return f"Backend added to existing frontend"
|
||||||
|
|
||||||
|
# ===== TWORZENIE NOWEGO FRONTENDU (GENERYCZNE NAZWY) =====
|
||||||
|
# Generuj generyczną nazwę frontendu
|
||||||
|
generic_frontend_name = f"https_frontend" if use_ssl else f"http_frontend"
|
||||||
|
|
||||||
|
print(f"[HAPROXY] Creating new frontend '{generic_frontend_name}' at {frontend_ip}:{frontend_port}", flush=True)
|
||||||
|
|
||||||
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
|
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
|
||||||
haproxy_cfg.write(f"\nfrontend {frontend_name}\n")
|
# ===== PRIMARY FRONTEND (GENERIC NAME) =====
|
||||||
|
haproxy_cfg.write(f"\nfrontend {generic_frontend_name}\n")
|
||||||
if is_frontend_exist(frontend_name, frontend_ip, frontend_port):
|
|
||||||
return "Frontend or Port already exists. Cannot add duplicate."
|
|
||||||
|
|
||||||
haproxy_cfg.write(f" bind {frontend_ip}:{frontend_port}")
|
haproxy_cfg.write(f" bind {frontend_ip}:{frontend_port}")
|
||||||
|
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
haproxy_cfg.write(f" ssl crt {ssl_cert_path}")
|
haproxy_cfg.write(f" ssl crt {ssl_cert_path}")
|
||||||
|
|
||||||
haproxy_cfg.write("\n")
|
haproxy_cfg.write("\n")
|
||||||
|
|
||||||
if https_redirect:
|
# Headers zaraz po BIND/CERT
|
||||||
haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n")
|
haproxy_cfg.write(f" http-request set-header X-Forwarded-For %[src]\n")
|
||||||
|
if use_ssl:
|
||||||
if forward_for:
|
haproxy_cfg.write(f" http-request set-header X-Forwarded-Proto https\n")
|
||||||
haproxy_cfg.write(f" option forwardfor\n")
|
else:
|
||||||
|
haproxy_cfg.write(f" http-request set-header X-Forwarded-Proto http\n")
|
||||||
|
|
||||||
haproxy_cfg.write(f" mode {protocol}\n")
|
haproxy_cfg.write(f" mode {protocol}\n")
|
||||||
haproxy_cfg.write(f" balance {lb_method}\n")
|
|
||||||
|
# ACL dla pierwszego vhost
|
||||||
# Add protection rules
|
acl_name_sanitized = None
|
||||||
|
if frontend_hostname:
|
||||||
|
acl_name_sanitized = f"is_{sanitize_name(frontend_hostname)}"
|
||||||
|
haproxy_cfg.write(f" acl {acl_name_sanitized} hdr(host) -i {frontend_hostname}\n")
|
||||||
|
|
||||||
|
if not is_no_lb:
|
||||||
|
haproxy_cfg.write(f" balance {lb_method}\n")
|
||||||
|
if forward_for:
|
||||||
|
haproxy_cfg.write(f" option forwardfor\n")
|
||||||
|
|
||||||
|
# Protections
|
||||||
if is_dos:
|
if is_dos:
|
||||||
haproxy_cfg.write(f" stick-table type ip size 1m expire {ban_duration} store http_req_rate(1m)\n")
|
haproxy_cfg.write(f" stick-table type ip size 1m expire {ban_duration} store http_req_rate(1m)\n")
|
||||||
haproxy_cfg.write(f" http-request track-sc0 src\n")
|
haproxy_cfg.write(f" http-request track-sc0 src\n")
|
||||||
haproxy_cfg.write(f" acl abuse sc_http_req_rate(0) gt {limit_requests}\n")
|
haproxy_cfg.write(f" acl abuse sc_http_req_rate(0) gt {limit_requests}\n")
|
||||||
haproxy_cfg.write(f" http-request silent-drop if abuse\n")
|
haproxy_cfg.write(f" http-request silent-drop if abuse\n")
|
||||||
|
|
||||||
if sql_injection_check:
|
if sql_injection_check:
|
||||||
# POPRAWNE escape sequence'i - podwójny backslash dla haproxy
|
|
||||||
haproxy_cfg.write(" acl is_sql_injection urlp_reg -i (union|select|insert|update|delete|drop|@@|1=1|`1)\n")
|
haproxy_cfg.write(" acl is_sql_injection urlp_reg -i (union|select|insert|update|delete|drop|@@|1=1|`1)\n")
|
||||||
haproxy_cfg.write(" acl is_long_uri path_len gt 400\n")
|
haproxy_cfg.write(" acl is_long_uri path_len gt 400\n")
|
||||||
haproxy_cfg.write(" acl semicolon_path path_reg -i ^.*;.*\n")
|
haproxy_cfg.write(" acl semicolon_path path_reg -i ^.*;.*\n")
|
||||||
haproxy_cfg.write(" acl is_sql_injection2 urlp_reg -i (;|substring|extract|union\\s+all|order\\s+by)\\s+(\\d+|--\\+)\n")
|
haproxy_cfg.write(" acl is_sql_injection2 urlp_reg -i (;|substring|extract|union\\s+all|order\\s+by)\\s+(\\d+|--\\+)\n")
|
||||||
haproxy_cfg.write(f" http-request deny if is_sql_injection or is_long_uri or semicolon_path or is_sql_injection2\n")
|
haproxy_cfg.write(f" http-request deny if is_sql_injection or is_long_uri or semicolon_path or is_sql_injection2\n")
|
||||||
|
|
||||||
if is_xss:
|
if is_xss:
|
||||||
haproxy_cfg.write(" acl is_xss_attack urlp_reg -i (<|>|script|alert|onerror|onload|javascript)\n")
|
haproxy_cfg.write(" acl is_xss_attack urlp_reg -i (<|>|script|alert|onerror|onload|javascript)\n")
|
||||||
haproxy_cfg.write(" acl is_xss_attack_2 urlp_reg -i (<\\s*script\\s*|javascript:|<\\s*img\\s*src\\s*=|<\\s*a\\s*href\\s*=|<\\s*iframe\\s*src\\s*=|\\bon\\w+\\s*=|<\\s*input\\s*[^>]*\\s*value\\s*=|<\\s*form\\s*action\\s*=|<\\s*svg\\s*on\\w+\\s*=)\n")
|
haproxy_cfg.write(" acl is_xss_attack_2 urlp_reg -i (<\\s*script\\s*|javascript:|<\\s*img\\s*src\\s*=|<\\s*a\\s*href\\s*=|<\\s*iframe\\s*src\\s*=|\\bon\\w+\\s*=|<\\s*input\\s*[^>]*\\s*value\\s*=|<\\s*form\\s*action\\s*=|<\\s*svg\\s*on\\w+\\s*=)\n")
|
||||||
haproxy_cfg.write(" acl is_xss_attack_hdr hdr_reg(Cookie|Referer|User-Agent) -i (<|>|script|alert|onerror|onload|javascript)\n")
|
haproxy_cfg.write(" acl is_xss_attack_hdr hdr_reg(Cookie|Referer|User-Agent) -i (<|>|script|alert|onerror|onload|javascript)\n")
|
||||||
haproxy_cfg.write(f" http-request deny if is_xss_attack or is_xss_attack_2 or is_xss_attack_hdr\n")
|
haproxy_cfg.write(f" http-request deny if is_xss_attack or is_xss_attack_2 or is_xss_attack_hdr\n")
|
||||||
|
|
||||||
if is_webshells:
|
if is_webshells:
|
||||||
haproxy_cfg.write(" acl blocked_webshell path_reg -i /(cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil).*\\.php.*\n")
|
haproxy_cfg.write(" acl blocked_webshell path_reg -i /(cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil).*\\.php.*\n")
|
||||||
haproxy_cfg.write(f" http-request deny if blocked_webshell\n")
|
haproxy_cfg.write(f" http-request deny if blocked_webshell\n")
|
||||||
|
|
||||||
haproxy_cfg.write(f" default_backend {backend_name}\n")
|
if https_redirect:
|
||||||
|
haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n")
|
||||||
# Backend section
|
|
||||||
haproxy_cfg.write(f"\nbackend {backend_name}\n")
|
if del_server_header:
|
||||||
haproxy_cfg.write(f" balance {lb_method}\n")
|
haproxy_cfg.write(f" http-response del-header Server\n")
|
||||||
|
|
||||||
if sticky_session:
|
# Backend routing
|
||||||
|
if acl_name_sanitized:
|
||||||
|
haproxy_cfg.write(f" use_backend {unique_backend_name} if {acl_name_sanitized}\n")
|
||||||
|
else:
|
||||||
|
haproxy_cfg.write(f" default_backend {unique_backend_name}\n")
|
||||||
|
|
||||||
|
# ===== BACKEND =====
|
||||||
|
haproxy_cfg.write(f"\nbackend {unique_backend_name}\n")
|
||||||
|
|
||||||
|
if not is_no_lb:
|
||||||
|
haproxy_cfg.write(f" balance {lb_method}\n")
|
||||||
|
|
||||||
|
if sticky_session and not is_no_lb:
|
||||||
if sticky_session_type == "cookie":
|
if sticky_session_type == "cookie":
|
||||||
haproxy_cfg.write(f" cookie SERVERID insert indirect nocache\n")
|
haproxy_cfg.write(f" cookie SERVERID insert indirect nocache\n")
|
||||||
elif sticky_session_type == "source":
|
elif sticky_session_type == "source":
|
||||||
haproxy_cfg.write(f" stick-table type ip size 200k expire 30m\n")
|
haproxy_cfg.write(f" stick-table type ip size 200k expire 30m\n")
|
||||||
haproxy_cfg.write(f" stick on src\n")
|
haproxy_cfg.write(f" stick on src\n")
|
||||||
|
|
||||||
if health_check and protocol == 'http':
|
if health_check and protocol == 'http':
|
||||||
haproxy_cfg.write(f" option httpchk GET {health_check_link}\n")
|
haproxy_cfg.write(f" option httpchk GET {health_check_link}\n")
|
||||||
elif health_check_tcp and protocol == 'tcp':
|
elif health_check_tcp and protocol == 'tcp':
|
||||||
haproxy_cfg.write(f" option tcp-check\n")
|
haproxy_cfg.write(f" option tcp-check\n")
|
||||||
|
|
||||||
if add_header:
|
if add_header:
|
||||||
haproxy_cfg.write(f" http-response add-header {header_name} {header_value}\n")
|
haproxy_cfg.write(f" http-response add-header {header_name} {header_value}\n")
|
||||||
|
|
||||||
# Add backend servers
|
if del_server_header:
|
||||||
|
haproxy_cfg.write(f" http-response del-header Server\n")
|
||||||
|
|
||||||
|
if forward_for:
|
||||||
|
haproxy_cfg.write(f" option forwardfor\n")
|
||||||
|
|
||||||
for server_name, server_ip, server_port, maxconn in backend_servers:
|
for server_name, server_ip, server_port, maxconn in backend_servers:
|
||||||
maxconn_str = f" maxconn {maxconn}" if maxconn else ""
|
maxconn_str = f" maxconn {maxconn}" if maxconn else ""
|
||||||
|
|
||||||
if health_check and protocol == 'http':
|
if health_check and protocol == 'http':
|
||||||
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str} check\n")
|
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str} check\n")
|
||||||
else:
|
else:
|
||||||
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str}\n")
|
haproxy_cfg.write(f" server {server_name} {server_ip}:{server_port}{maxconn_str}\n")
|
||||||
|
|
||||||
return "Configuration updated successfully!"
|
# ===== REDIRECT HTTP -> HTTPS (GENERIC NAME) =====
|
||||||
|
if backend_ssl_redirect and ssl_redirect_backend_name:
|
||||||
|
unique_redirect_backend_name = f"{ssl_redirect_backend_name}_redirect_{sanitize_name(frontend_hostname)}" if frontend_hostname else f"{ssl_redirect_backend_name}_redirect"
|
||||||
|
|
||||||
|
# Check if HTTP frontend exists
|
||||||
|
existing_http_frontend = frontend_exists_at_port(frontend_ip, ssl_redirect_port)
|
||||||
|
|
||||||
|
if not existing_http_frontend:
|
||||||
|
generic_http_redirect_name = f"http_redirect_frontend"
|
||||||
|
|
||||||
|
haproxy_cfg.write(f"\nfrontend {generic_http_redirect_name}\n")
|
||||||
|
haproxy_cfg.write(f" bind {frontend_ip}:{ssl_redirect_port}\n")
|
||||||
|
haproxy_cfg.write(f" mode http\n")
|
||||||
|
|
||||||
|
if frontend_hostname:
|
||||||
|
acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect"
|
||||||
|
haproxy_cfg.write(f" acl {acl_name_redirect} hdr(host) -i {frontend_hostname}\n")
|
||||||
|
haproxy_cfg.write(f" use_backend {unique_redirect_backend_name} if {acl_name_redirect}\n")
|
||||||
|
else:
|
||||||
|
haproxy_cfg.write(f" default_backend {unique_redirect_backend_name}\n")
|
||||||
|
else:
|
||||||
|
if frontend_hostname:
|
||||||
|
acl_name_redirect = f"is_{sanitize_name(frontend_hostname)}_redirect"
|
||||||
|
add_acl_to_frontend(existing_http_frontend, acl_name_redirect, frontend_hostname, unique_redirect_backend_name)
|
||||||
|
|
||||||
|
# Redirect backend
|
||||||
|
haproxy_cfg.write(f"\nbackend {unique_redirect_backend_name}\n")
|
||||||
|
haproxy_cfg.write(f" mode http\n")
|
||||||
|
haproxy_cfg.write(f" redirect scheme https code 301 if !{{ ssl_fc }}\n")
|
||||||
|
|
||||||
|
return "Configuration updated successfully!"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HAPROXY_CONFIG] Error updating config: {e}", flush=True)
|
print(f"[HAPROXY_CONFIG] Error updating config: {e}", flush=True)
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import requests
|
import requests
|
||||||
import csv
|
import csv
|
||||||
|
|
||||||
HAPROXY_STATS_URL = 'http://127.0.0.1:8484/;csv'
|
HAPROXY_STATS_URL = 'http://127.0.0.1:8404/stats;csv'
|
||||||
|
|
||||||
def fetch_haproxy_stats():
|
def fetch_haproxy_stats():
|
||||||
try:
|
try:
|
||||||
@@ -13,18 +13,58 @@ def fetch_haproxy_stats():
|
|||||||
|
|
||||||
def parse_haproxy_stats(stats_data):
|
def parse_haproxy_stats(stats_data):
|
||||||
data = []
|
data = []
|
||||||
header_row = stats_data.splitlines()[0].replace('# ', '')
|
|
||||||
reader = csv.DictReader(stats_data.splitlines(), fieldnames=header_row.split(','))
|
lines = [line for line in stats_data.splitlines() if line.strip()]
|
||||||
|
if not lines:
|
||||||
|
return data
|
||||||
|
|
||||||
|
header_row = lines[0].replace('# ', '')
|
||||||
|
|
||||||
|
# Parse CSV
|
||||||
|
reader = csv.DictReader(lines, fieldnames=header_row.split(','))
|
||||||
next(reader)
|
next(reader)
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
if row['svname'] != 'BACKEND':
|
if row.get('svname') == 'BACKEND':
|
||||||
data.append({
|
continue
|
||||||
'frontend_name': row['pxname'],
|
|
||||||
'server_name': row['svname'],
|
row = {k: v.strip() if isinstance(v, str) else v for k, v in row.items()}
|
||||||
'4xx_errors': row['hrsp_4xx'],
|
|
||||||
'5xx_errors': row['hrsp_5xx'],
|
try:
|
||||||
'bytes_in_mb': f'{float(row["bin"]) / (1024 * 1024):.2f}',
|
conn_tot = int(row.get('conn_tot', 0) or 0)
|
||||||
'bytes_out_mb': f'{float(row["bout"]) / (1024 * 1024):.2f}',
|
except (ValueError, TypeError):
|
||||||
'conn_tot': row['conn_tot'],
|
conn_tot = 0
|
||||||
})
|
|
||||||
return data
|
try:
|
||||||
|
hrsp_4xx = int(row.get('hrsp_4xx', 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
hrsp_4xx = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
hrsp_5xx = int(row.get('hrsp_5xx', 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
hrsp_5xx = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
bin_bytes = float(row.get('bin', 0) or 0)
|
||||||
|
bytes_in_mb = bin_bytes / (1024 * 1024)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
bytes_in_mb = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
bout_bytes = float(row.get('bout', 0) or 0)
|
||||||
|
bytes_out_mb = bout_bytes / (1024 * 1024)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
bytes_out_mb = 0.0
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'frontend_name': row.get('pxname', 'Unknown'),
|
||||||
|
'server_name': row.get('svname', 'Unknown'),
|
||||||
|
'4xx_errors': hrsp_4xx,
|
||||||
|
'5xx_errors': hrsp_5xx,
|
||||||
|
'bytes_in_mb': bytes_in_mb,
|
||||||
|
'bytes_out_mb': bytes_out_mb,
|
||||||
|
'conn_tot': conn_tot,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|||||||
Reference in New Issue
Block a user