Replace Basic Auth with login page, add HTTP redirect and log viewer

- Session-based login page instead of HTTP Basic Auth
- /ca.crt download works without login for easy device access
- HTTP port (default 8080) redirects to HTTPS automatically
- Nginx access/error log viewer in WebUI
- API still supports Basic Auth for curl/scripts
- Logout button and log navigation in header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-09 16:06:55 +02:00
parent 5a8770e973
commit 6fadb73263
8 changed files with 378 additions and 30 deletions

View File

@ -13,5 +13,6 @@ CERT_DAYS=36500
# --- WebUI-Einstellungen --- # --- WebUI-Einstellungen ---
WEBUI_PORT=8443 WEBUI_PORT=8443
WEBUI_PORT_HTTP=8080
WEBUI_USERNAME=admin WEBUI_USERNAME=admin
WEBUI_PASSWORD=admin123 WEBUI_PASSWORD=admin123

View File

@ -1,16 +1,21 @@
import json import json
import os import os
import secrets
import subprocess import subprocess
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, redirect, render_template, request, send_file, url_for from flask import (Flask, jsonify, redirect, render_template, request,
send_file, session, url_for)
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
CONFIG_FILE = "/data/proxy_config.json" CONFIG_FILE = "/data/proxy_config.json"
NGINX_CONF_DIR = "/etc/nginx/conf.d" NGINX_CONF_DIR = "/etc/nginx/conf.d"
NGINX_UPSTREAM_CONF = f"{NGINX_CONF_DIR}/proxy-targets.conf" NGINX_UPSTREAM_CONF = f"{NGINX_CONF_DIR}/proxy-targets.conf"
NGINX_ACCESS_LOG = "/var/log/nginx/access.log"
NGINX_ERROR_LOG = "/var/log/nginx/error.log"
USERNAME = os.environ.get("WEBUI_USERNAME", "admin") USERNAME = os.environ.get("WEBUI_USERNAME", "admin")
PASSWORD = os.environ.get("WEBUI_PASSWORD", "admin123") PASSWORD = os.environ.get("WEBUI_PASSWORD", "admin123")
@ -131,29 +136,55 @@ def reload_nginx():
return False, str(e) return False, str(e)
def check_auth(f): def read_log_tail(filepath, lines=100):
"""Read the last N lines of a log file."""
try:
result = subprocess.run(
["tail", "-n", str(lines), filepath],
capture_output=True, text=True, timeout=5
)
return result.stdout
except Exception:
return ""
def login_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
auth = request.authorization if not session.get("logged_in"):
if not auth or auth.username != USERNAME or auth.password != PASSWORD: # API requests get 401, browser requests get redirected
return ( if request.path.startswith("/api/"):
"Authentication required", auth = request.authorization
401, if auth and auth.username == USERNAME and auth.password == PASSWORD:
{"WWW-Authenticate": 'Basic realm="Proxy Admin"'}, return f(*args, **kwargs)
) return jsonify({"error": "Authentication required"}), 401
return redirect(url_for("login"))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
# ==================== WebUI Routes ==================== # ==================== Auth Routes ====================
@app.route("/") @app.route("/login", methods=["GET", "POST"])
@check_auth def login():
def index(): error = None
config = load_config() if request.method == "POST":
return render_template("index.html", config=config) if (request.form.get("username") == USERNAME and
request.form.get("password") == PASSWORD):
session["logged_in"] = True
return redirect(url_for("index"))
error = "Falscher Benutzername oder Passwort"
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ==================== Public Routes ====================
@app.route("/ca.crt") @app.route("/ca.crt")
def download_ca(): def download_ca():
"""Download CA certificate - no auth required so devices can easily fetch it.""" """Download CA certificate - no auth required so devices can easily fetch it."""
@ -164,8 +195,27 @@ def download_ca():
mimetype="application/x-x509-ca-cert") mimetype="application/x-x509-ca-cert")
# ==================== WebUI Routes ====================
@app.route("/")
@login_required
def index():
config = load_config()
return render_template("index.html", config=config)
@app.route("/logs")
@login_required
def logs():
lines = int(request.args.get("lines", 100))
log_type = request.args.get("type", "access")
log_file = NGINX_ACCESS_LOG if log_type == "access" else NGINX_ERROR_LOG
log_content = read_log_tail(log_file, lines)
return render_template("logs.html", log_content=log_content, log_type=log_type, lines=lines)
@app.route("/target/add", methods=["POST"]) @app.route("/target/add", methods=["POST"])
@check_auth @login_required
def add_target(): def add_target():
config = load_config() config = load_config()
@ -198,7 +248,7 @@ def add_target():
@app.route("/target/<int:idx>/delete", methods=["POST"]) @app.route("/target/<int:idx>/delete", methods=["POST"])
@check_auth @login_required
def delete_target(idx): def delete_target(idx):
config = load_config() config = load_config()
if 0 <= idx < len(config["targets"]): if 0 <= idx < len(config["targets"]):
@ -210,7 +260,7 @@ def delete_target(idx):
@app.route("/target/<int:idx>/toggle", methods=["POST"]) @app.route("/target/<int:idx>/toggle", methods=["POST"])
@check_auth @login_required
def toggle_target(idx): def toggle_target(idx):
config = load_config() config = load_config()
if 0 <= idx < len(config["targets"]): if 0 <= idx < len(config["targets"]):
@ -222,7 +272,7 @@ def toggle_target(idx):
@app.route("/target/<int:idx>/edit", methods=["POST"]) @app.route("/target/<int:idx>/edit", methods=["POST"])
@check_auth @login_required
def edit_target(idx): def edit_target(idx):
config = load_config() config = load_config()
if 0 <= idx < len(config["targets"]): if 0 <= idx < len(config["targets"]):
@ -251,14 +301,14 @@ def edit_target(idx):
# ==================== API Routes ==================== # ==================== API Routes ====================
@app.route("/api/targets", methods=["GET"]) @app.route("/api/targets", methods=["GET"])
@check_auth @login_required
def api_list_targets(): def api_list_targets():
config = load_config() config = load_config()
return jsonify(config) return jsonify(config)
@app.route("/api/targets", methods=["POST"]) @app.route("/api/targets", methods=["POST"])
@check_auth @login_required
def api_add_target(): def api_add_target():
config = load_config() config = load_config()
target = request.get_json() target = request.get_json()
@ -282,7 +332,7 @@ def api_add_target():
@app.route("/api/targets/<int:idx>", methods=["PUT"]) @app.route("/api/targets/<int:idx>", methods=["PUT"])
@check_auth @login_required
def api_update_target(idx): def api_update_target(idx):
config = load_config() config = load_config()
if idx < 0 or idx >= len(config["targets"]): if idx < 0 or idx >= len(config["targets"]):
@ -301,7 +351,7 @@ def api_update_target(idx):
@app.route("/api/targets/<int:idx>", methods=["DELETE"]) @app.route("/api/targets/<int:idx>", methods=["DELETE"])
@check_auth @login_required
def api_delete_target(idx): def api_delete_target(idx):
config = load_config() config = load_config()
if idx < 0 or idx >= len(config["targets"]): if idx < 0 or idx >= len(config["targets"]):
@ -316,7 +366,7 @@ def api_delete_target(idx):
@app.route("/api/reload", methods=["POST"]) @app.route("/api/reload", methods=["POST"])
@check_auth @login_required
def api_reload(): def api_reload():
config = load_config() config = load_config()
generate_nginx_config(config) generate_nginx_config(config)
@ -324,5 +374,14 @@ def api_reload():
return jsonify({"status": "ok" if success else "error", "message": msg}) return jsonify({"status": "ok" if success else "error", "message": msg})
@app.route("/api/logs", methods=["GET"])
@login_required
def api_logs():
lines = int(request.args.get("lines", 100))
log_type = request.args.get("type", "access")
log_file = NGINX_ACCESS_LOG if log_type == "access" else NGINX_ERROR_LOG
return jsonify({"type": log_type, "content": read_log_tail(log_file, lines)})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -199,6 +199,8 @@
<h1>HTTPS Reverse Proxy</h1> <h1>HTTPS Reverse Proxy</h1>
<div style="display:flex;align-items:center;gap:15px;"> <div style="display:flex;align-items:center;gap:15px;">
<a href="/ca.crt" class="btn btn-sm btn-primary" style="text-decoration:none;">CA-Zertifikat herunterladen</a> <a href="/ca.crt" class="btn btn-sm btn-primary" style="text-decoration:none;">CA-Zertifikat herunterladen</a>
<a href="/logs" class="btn btn-sm" style="text-decoration:none;background:#0f3460;color:#e0e0e0;">Logs</a>
<a href="/logout" class="btn btn-sm" style="text-decoration:none;background:#e74c3c;color:white;">Abmelden</a>
<span class="badge">Self-Signed SSL - 100 Jahre</span> <span class="badge">Self-Signed SSL - 100 Jahre</span>
</div> </div>
</div> </div>

123
app/templates/login.html Normal file
View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTPS Proxy - Login</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: #16213e;
border-radius: 10px;
padding: 40px;
border: 1px solid #0f3460;
width: 100%;
max-width: 400px;
}
.login-card h1 {
color: #00d4ff;
font-size: 1.5em;
margin-bottom: 8px;
text-align: center;
}
.login-card .subtitle {
color: #8899aa;
text-align: center;
margin-bottom: 30px;
font-size: 0.9em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-size: 0.85em;
color: #8899aa;
margin-bottom: 5px;
display: block;
}
input {
background: #1a1a2e;
border: 1px solid #0f3460;
color: #e0e0e0;
padding: 12px 14px;
border-radius: 6px;
font-size: 0.95em;
width: 100%;
}
input:focus {
outline: none;
border-color: #00d4ff;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
width: 100%;
background: #00d4ff;
color: #1a1a2e;
transition: all 0.2s;
}
.btn:hover { background: #00b8d4; }
.error {
background: rgba(231, 76, 60, 0.2);
border: 1px solid #e74c3c;
color: #e74c3c;
padding: 10px;
border-radius: 6px;
margin-bottom: 20px;
text-align: center;
font-size: 0.9em;
}
.ca-link {
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #0f3460;
}
.ca-link a {
color: #00d4ff;
text-decoration: none;
font-size: 0.85em;
}
.ca-link a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="login-card">
<h1>HTTPS Reverse Proxy</h1>
<p class="subtitle">Anmeldung erforderlich</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>Benutzername</label>
<input type="text" name="username" autofocus required>
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">Anmelden</button>
</form>
<div class="ca-link">
<a href="/ca.crt">CA-Zertifikat herunterladen</a>
</div>
</div>
</body>
</html>

157
app/templates/logs.html Normal file
View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTPS Proxy - Logs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
.header {
background: #16213e;
padding: 20px 30px;
border-bottom: 2px solid #0f3460;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { color: #00d4ff; font-size: 1.5em; }
.header-right { display: flex; align-items: center; gap: 15px; }
.badge {
background: #0f3460;
color: #00d4ff;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.85em;
}
.nav-link {
color: #00d4ff;
text-decoration: none;
font-size: 0.9em;
}
.nav-link:hover { text-decoration: underline; }
.container { max-width: 1400px; margin: 30px auto; padding: 0 20px; }
.card {
background: #16213e;
border-radius: 10px;
padding: 25px;
margin-bottom: 25px;
border: 1px solid #0f3460;
}
.card h2 {
color: #00d4ff;
margin-bottom: 20px;
font-size: 1.2em;
border-bottom: 1px solid #0f3460;
padding-bottom: 10px;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary { background: #00d4ff; color: #1a1a2e; }
.btn-primary:hover { background: #00b8d4; }
.btn-active { background: #00d4ff; color: #1a1a2e; }
.btn-inactive { background: #0f3460; color: #8899aa; }
.btn-inactive:hover { background: #1a3a6e; color: #e0e0e0; }
select {
background: #1a1a2e;
border: 1px solid #0f3460;
color: #e0e0e0;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85em;
}
.log-output {
background: #0d0d1a;
border: 1px solid #0f3460;
border-radius: 6px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 0.8em;
line-height: 1.6;
overflow-x: auto;
max-height: 70vh;
overflow-y: auto;
white-space: pre;
color: #b0b0b0;
}
.empty-log {
color: #8899aa;
text-align: center;
padding: 40px;
}
</style>
</head>
<body>
<div class="header">
<h1>HTTPS Reverse Proxy - Logs</h1>
<div class="header-right">
<a href="/" class="nav-link">Proxy-Ziele</a>
<a href="/logout" class="nav-link">Abmelden</a>
</div>
</div>
<div class="container">
<div class="card">
<h2>Nginx Logs</h2>
<div class="controls">
<a href="/logs?type=access&lines={{ lines }}"
class="btn {{ 'btn-active' if log_type == 'access' else 'btn-inactive' }}">Access Log</a>
<a href="/logs?type=error&lines={{ lines }}"
class="btn {{ 'btn-active' if log_type == 'error' else 'btn-inactive' }}">Error Log</a>
<form method="GET" action="/logs" style="display:flex;gap:10px;align-items:center;">
<input type="hidden" name="type" value="{{ log_type }}">
<label style="color:#8899aa;font-size:0.85em;">Zeilen:</label>
<select name="lines" onchange="this.form.submit()">
<option value="50" {{ "selected" if lines == 50 }}>50</option>
<option value="100" {{ "selected" if lines == 100 }}>100</option>
<option value="250" {{ "selected" if lines == 250 }}>250</option>
<option value="500" {{ "selected" if lines == 500 }}>500</option>
<option value="1000" {{ "selected" if lines == 1000 }}>1000</option>
</select>
</form>
<a href="/logs?type={{ log_type }}&lines={{ lines }}" class="btn btn-primary">Aktualisieren</a>
</div>
{% if log_content %}
<div class="log-output">{{ log_content }}</div>
{% else %}
<div class="empty-log">
<p>Keine Log-Eintraege vorhanden.</p>
</div>
{% endif %}
</div>
</div>
<script>
// Auto-scroll to bottom
const logOutput = document.querySelector('.log-output');
if (logOutput) {
logOutput.scrollTop = logOutput.scrollHeight;
}
</script>
</body>
</html>

View File

@ -14,6 +14,7 @@ services:
- CERT_DAYS=${CERT_DAYS:-36500} - CERT_DAYS=${CERT_DAYS:-36500}
# WebUI settings # WebUI settings
- WEBUI_PORT=${WEBUI_PORT:-8443} - WEBUI_PORT=${WEBUI_PORT:-8443}
- WEBUI_PORT_HTTP=${WEBUI_PORT_HTTP:-8080}
- WEBUI_USERNAME=${WEBUI_USERNAME:-admin} - WEBUI_USERNAME=${WEBUI_USERNAME:-admin}
- WEBUI_PASSWORD=${WEBUI_PASSWORD:-admin123} - WEBUI_PASSWORD=${WEBUI_PASSWORD:-admin123}
network_mode: host network_mode: host

View File

@ -3,14 +3,15 @@ set -e
echo "=== Starting HTTPS Proxy ===" echo "=== Starting HTTPS Proxy ==="
# Set default for WEBUI_PORT # Set defaults
export WEBUI_PORT="${WEBUI_PORT:-8443}" export WEBUI_PORT="${WEBUI_PORT:-8443}"
export WEBUI_PORT_HTTP="${WEBUI_PORT_HTTP:-8080}"
# Generate certificates if needed # Generate certificates if needed
/certs/generate-certs.sh /certs/generate-certs.sh
# Replace env vars in nginx config template # Replace env vars in nginx config template
envsubst '${WEBUI_PORT}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf envsubst '${WEBUI_PORT} ${WEBUI_PORT_HTTP}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
# Ensure conf.d directory exists with empty config # Ensure conf.d directory exists with empty config
mkdir -p /etc/nginx/conf.d mkdir -p /etc/nginx/conf.d

View File

@ -18,7 +18,14 @@ http {
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
# WebUI / Admin interface # HTTP -> HTTPS redirect
server {
listen ${WEBUI_PORT_HTTP};
server_name _;
return 301 https://$host:${WEBUI_PORT}$request_uri;
}
# WebUI / Admin interface (HTTPS)
server { server {
listen ${WEBUI_PORT} ssl default_server; listen ${WEBUI_PORT} ssl default_server;
server_name _; server_name _;
@ -27,9 +34,6 @@ http {
ssl_certificate_key /certs/server.key; ssl_certificate_key /certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
# Redirect plain HTTP requests to HTTPS
error_page 497 https://$host:$server_port$request_uri;
location / { location / {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host; proxy_set_header Host $host;