diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b7f4bd --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# ============================================ +# HTTPS Proxy - Konfiguration +# ============================================ + +# --- Zertifikat-Einstellungen --- +CERT_COUNTRY=DE +CERT_STATE=Bavaria +CERT_CITY=Munich +CERT_ORG=MyOrganization +CERT_OU=IT +CERT_CN=proxy.local +CERT_DAYS=36500 + +# --- WebUI-Einstellungen --- +WEBUI_PORT=8443 +WEBUI_USERNAME=admin +WEBUI_PASSWORD=admin123 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d00e1f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM nginx:alpine + +# Install Python, pip, openssl +RUN apk add --no-cache python3 py3-pip openssl bash \ + && python3 -m venv /opt/venv + +ENV PATH="/opt/venv/bin:$PATH" + +# Install Python dependencies +COPY app/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# Copy application files +COPY app/ /app/ +COPY nginx/nginx.conf /etc/nginx/nginx.conf.template +COPY nginx/entrypoint.sh /entrypoint.sh +COPY certs/generate-certs.sh /certs/generate-certs.sh + +RUN chmod +x /entrypoint.sh /certs/generate-certs.sh \ + && mkdir -p /data /etc/nginx/conf.d + +# No EXPOSE needed - running in host network mode + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..3e4228b --- /dev/null +++ b/app/app.py @@ -0,0 +1,318 @@ +import json +import os +import subprocess +from functools import wraps +from pathlib import Path + +from flask import Flask, jsonify, redirect, render_template, request, url_for + +app = Flask(__name__) + +CONFIG_FILE = "/data/proxy_config.json" +NGINX_CONF_DIR = "/etc/nginx/conf.d" +NGINX_UPSTREAM_CONF = f"{NGINX_CONF_DIR}/proxy-targets.conf" + +USERNAME = os.environ.get("WEBUI_USERNAME", "admin") +PASSWORD = os.environ.get("WEBUI_PASSWORD", "admin123") + + +def load_config(): + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE) as f: + return json.load(f) + return {"targets": []} + + +def save_config(config): + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + + +def generate_nginx_config(config): + """Generate nginx upstream/server blocks from config.""" + lines = [] + + for i, target in enumerate(config.get("targets", [])): + name = target.get("name", f"target_{i}") + target_host = target.get("target_host", "") + target_port = target.get("target_port", 80) + listen_port = target.get("listen_port", 0) + domains = target.get("domains", []) + target_scheme = target.get("target_scheme", "http") + + if not target_host or not target.get("enabled", True): + continue + + upstream_name = f"upstream_{name}" + lines.append(f"upstream {upstream_name} {{") + lines.append(f" server {target_host}:{target_port};") + lines.append("}") + lines.append("") + + # Domain-based routing + if domains: + for domain_entry in domains: + domain = domain_entry.get("domain", "") + domain_port = domain_entry.get("port", 443) + if not domain: + continue + lines.append("server {") + lines.append(f" listen {domain_port} ssl;") + lines.append(f" server_name {domain};") + lines.append("") + lines.append(" ssl_certificate /certs/server.crt;") + lines.append(" ssl_certificate_key /certs/server.key;") + lines.append(" ssl_protocols TLSv1.2 TLSv1.3;") + lines.append("") + lines.append(f" location / {{") + lines.append(f" proxy_pass {target_scheme}://{upstream_name};") + lines.append(" proxy_set_header Host $host;") + lines.append(" proxy_set_header X-Real-IP $remote_addr;") + lines.append(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;") + lines.append(" proxy_set_header X-Forwarded-Proto $scheme;") + lines.append(" proxy_http_version 1.1;") + lines.append(' proxy_set_header Upgrade $http_upgrade;') + lines.append(' proxy_set_header Connection "upgrade";') + lines.append(" }") + lines.append("}") + lines.append("") + + # IP/Port-based routing + if listen_port: + lines.append("server {") + lines.append(f" listen {listen_port} ssl;") + lines.append(f" server_name _;") + lines.append("") + lines.append(" ssl_certificate /certs/server.crt;") + lines.append(" ssl_certificate_key /certs/server.key;") + lines.append(" ssl_protocols TLSv1.2 TLSv1.3;") + lines.append("") + lines.append(f" location / {{") + lines.append(f" proxy_pass {target_scheme}://{upstream_name};") + lines.append(" proxy_set_header Host $host;") + lines.append(" proxy_set_header X-Real-IP $remote_addr;") + lines.append(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;") + lines.append(" proxy_set_header X-Forwarded-Proto $scheme;") + lines.append(" proxy_http_version 1.1;") + lines.append(' proxy_set_header Upgrade $http_upgrade;') + lines.append(' proxy_set_header Connection "upgrade";') + lines.append(" }") + lines.append("}") + lines.append("") + + conf_content = "\n".join(lines) + os.makedirs(NGINX_CONF_DIR, exist_ok=True) + with open(NGINX_UPSTREAM_CONF, "w") as f: + f.write(conf_content) + + return conf_content + + +def reload_nginx(): + """Reload nginx configuration.""" + try: + result = subprocess.run( + ["nginx", "-t"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + return False, f"Nginx config test failed: {result.stderr}" + + result = subprocess.run( + ["nginx", "-s", "reload"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + return False, f"Nginx reload failed: {result.stderr}" + + return True, "Nginx reloaded successfully" + except Exception as e: + return False, str(e) + + +def check_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or auth.username != USERNAME or auth.password != PASSWORD: + return ( + "Authentication required", + 401, + {"WWW-Authenticate": 'Basic realm="Proxy Admin"'}, + ) + return f(*args, **kwargs) + return decorated + + +# ==================== WebUI Routes ==================== + +@app.route("/") +@check_auth +def index(): + config = load_config() + return render_template("index.html", config=config) + + +@app.route("/target/add", methods=["POST"]) +@check_auth +def add_target(): + config = load_config() + + domains = [] + domain_names = request.form.getlist("domain_name[]") + domain_ports = request.form.getlist("domain_port[]") + for name, port in zip(domain_names, domain_ports): + if name.strip(): + domains.append({"domain": name.strip(), "port": int(port) if port else 443}) + + target = { + "name": request.form.get("name", "").strip().replace(" ", "_"), + "target_host": request.form.get("target_host", "").strip(), + "target_port": int(request.form.get("target_port", 80)), + "target_scheme": request.form.get("target_scheme", "http"), + "listen_port": int(request.form.get("listen_port", 0) or 0), + "domains": domains, + "enabled": True, + } + + if not target["name"] or not target["target_host"]: + return redirect(url_for("index")) + + config["targets"].append(target) + save_config(config) + generate_nginx_config(config) + reload_nginx() + + return redirect(url_for("index")) + + +@app.route("/target//delete", methods=["POST"]) +@check_auth +def delete_target(idx): + config = load_config() + if 0 <= idx < len(config["targets"]): + config["targets"].pop(idx) + save_config(config) + generate_nginx_config(config) + reload_nginx() + return redirect(url_for("index")) + + +@app.route("/target//toggle", methods=["POST"]) +@check_auth +def toggle_target(idx): + config = load_config() + if 0 <= idx < len(config["targets"]): + config["targets"][idx]["enabled"] = not config["targets"][idx].get("enabled", True) + save_config(config) + generate_nginx_config(config) + reload_nginx() + return redirect(url_for("index")) + + +@app.route("/target//edit", methods=["POST"]) +@check_auth +def edit_target(idx): + config = load_config() + if 0 <= idx < len(config["targets"]): + domains = [] + domain_names = request.form.getlist("domain_name[]") + domain_ports = request.form.getlist("domain_port[]") + for name, port in zip(domain_names, domain_ports): + if name.strip(): + domains.append({"domain": name.strip(), "port": int(port) if port else 443}) + + config["targets"][idx] = { + "name": request.form.get("name", "").strip().replace(" ", "_"), + "target_host": request.form.get("target_host", "").strip(), + "target_port": int(request.form.get("target_port", 80)), + "target_scheme": request.form.get("target_scheme", "http"), + "listen_port": int(request.form.get("listen_port", 0) or 0), + "domains": domains, + "enabled": config["targets"][idx].get("enabled", True), + } + save_config(config) + generate_nginx_config(config) + reload_nginx() + return redirect(url_for("index")) + + +# ==================== API Routes ==================== + +@app.route("/api/targets", methods=["GET"]) +@check_auth +def api_list_targets(): + config = load_config() + return jsonify(config) + + +@app.route("/api/targets", methods=["POST"]) +@check_auth +def api_add_target(): + config = load_config() + target = request.get_json() + if not target: + return jsonify({"error": "Invalid JSON"}), 400 + if not target.get("name") or not target.get("target_host"): + return jsonify({"error": "name and target_host are required"}), 400 + + target.setdefault("target_port", 80) + target.setdefault("target_scheme", "http") + target.setdefault("listen_port", 0) + target.setdefault("domains", []) + target.setdefault("enabled", True) + + config["targets"].append(target) + save_config(config) + generate_nginx_config(config) + success, msg = reload_nginx() + + return jsonify({"status": "ok" if success else "warning", "message": msg, "target": target}), 201 + + +@app.route("/api/targets/", methods=["PUT"]) +@check_auth +def api_update_target(idx): + config = load_config() + if idx < 0 or idx >= len(config["targets"]): + return jsonify({"error": "Target not found"}), 404 + + target = request.get_json() + if not target: + return jsonify({"error": "Invalid JSON"}), 400 + + config["targets"][idx] = target + save_config(config) + generate_nginx_config(config) + success, msg = reload_nginx() + + return jsonify({"status": "ok" if success else "warning", "message": msg}) + + +@app.route("/api/targets/", methods=["DELETE"]) +@check_auth +def api_delete_target(idx): + config = load_config() + if idx < 0 or idx >= len(config["targets"]): + return jsonify({"error": "Target not found"}), 404 + + removed = config["targets"].pop(idx) + save_config(config) + generate_nginx_config(config) + success, msg = reload_nginx() + + return jsonify({"status": "ok" if success else "warning", "message": msg, "removed": removed}) + + +@app.route("/api/reload", methods=["POST"]) +@check_auth +def api_reload(): + config = load_config() + generate_nginx_config(config) + success, msg = reload_nginx() + return jsonify({"status": "ok" if success else "error", "message": msg}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..c812721 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.1 +gunicorn==23.0.0 diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..99bec27 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,410 @@ + + + + + + HTTPS Proxy - Admin + + + +
+

HTTPS Reverse Proxy

+ Self-Signed SSL - 100 Jahre +
+ +
+ +
+

Neues Proxy-Ziel hinzufuegen

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+
+ + + +
+
+ +
+ +
+ +
+
+
+ + +
+

Aktive Proxy-Ziele ({{ config.targets | length }})

+ + {% if config.targets %} + {% for target in config.targets %} +
+
+
+ {{ target.name }} + {% if target.enabled %} + ● Aktiv + {% else %} + ● Deaktiviert + {% endif %} +
+
+ +
+ +
+
+ +
+
+
+ +
+
+ Ziel: {{ target.target_scheme }}://{{ target.target_host }}:{{ target.target_port }} +
+ {% if target.listen_port %} +
+ Listen-Port: {{ target.listen_port }} +
+ {% endif %} + {% for d in target.domains %} +
+ Domain: {{ d.domain }}:{{ d.port }} +
+ {% endfor %} +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+ {% for d in target.domains %} +
+ + + +
+ {% endfor %} +
+ +
+
+ + +
+
+
+
+ {% endfor %} + {% else %} +
+

Keine Proxy-Ziele konfiguriert

+

Fuege oben ein neues Ziel hinzu, um den Proxy zu starten.

+
+ {% endif %} +
+ + +
+

API-Dokumentation

+

+ Alle Endpunkte erfordern HTTP Basic Auth (gleiche Zugangsdaten wie die WebUI). +

+ +
+

GET /api/targets - Alle Ziele auflisten

+
+
+

POST /api/targets - Neues Ziel hinzufuegen

+
curl -k -u admin:password -X POST https://localhost:8443/api/targets \
+  -H "Content-Type: application/json" \
+  -d '{"name":"my-app","target_host":"192.168.1.50","target_port":3000,
+       "listen_port":9443,"domains":[{"domain":"app.local","port":443}]}'
+
+
+

PUT /api/targets/<id> - Ziel aktualisieren

+
+
+

DELETE /api/targets/<id> - Ziel loeschen

+
+
+

POST /api/reload - Nginx-Konfiguration neu laden

+
+
+
+ + + + diff --git a/certs/generate-certs.sh b/certs/generate-certs.sh new file mode 100755 index 0000000..98059f5 --- /dev/null +++ b/certs/generate-certs.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -e + +CERT_DIR="/certs" +CA_KEY="$CERT_DIR/ca.key" +CA_CERT="$CERT_DIR/ca.crt" +SERVER_KEY="$CERT_DIR/server.key" +SERVER_CSR="$CERT_DIR/server.csr" +SERVER_CERT="$CERT_DIR/server.crt" + +# Defaults +CERT_COUNTRY="${CERT_COUNTRY:-DE}" +CERT_STATE="${CERT_STATE:-Bavaria}" +CERT_CITY="${CERT_CITY:-Munich}" +CERT_ORG="${CERT_ORG:-MyOrganization}" +CERT_OU="${CERT_OU:-IT}" +CERT_CN="${CERT_CN:-proxy.local}" +CERT_DAYS="${CERT_DAYS:-36500}" + +# Skip if certs already exist +if [ -f "$CA_CERT" ] && [ -f "$SERVER_CERT" ] && [ -f "$SERVER_KEY" ]; then + echo "Certificates already exist. Skipping generation." + echo "Delete files in $CERT_DIR to regenerate." + exit 0 +fi + +echo "=== Generating CA (Certificate Authority) ===" +openssl genrsa -out "$CA_KEY" 4096 + +openssl req -new -x509 -days "$CERT_DAYS" -key "$CA_KEY" -out "$CA_CERT" \ + -subj "/C=$CERT_COUNTRY/ST=$CERT_STATE/L=$CERT_CITY/O=$CERT_ORG/OU=$CERT_OU/CN=$CERT_CN CA" + +echo "=== Generating Server Certificate ===" +openssl genrsa -out "$SERVER_KEY" 4096 + +openssl req -new -key "$SERVER_KEY" -out "$SERVER_CSR" \ + -subj "/C=$CERT_COUNTRY/ST=$CERT_STATE/L=$CERT_CITY/O=$CERT_ORG/OU=$CERT_OU/CN=$CERT_CN" + +# Create extension file for SAN (Subject Alternative Names) +cat > "$CERT_DIR/server.ext" < /etc/nginx/nginx.conf + +# Ensure conf.d directory exists with empty config +mkdir -p /etc/nginx/conf.d +touch /etc/nginx/conf.d/proxy-targets.conf + +# Load existing config and generate nginx config +if [ -f /data/proxy_config.json ]; then + echo "Loading existing proxy configuration..." + python3 -c " +import sys +sys.path.insert(0, '/app') +from app import load_config, generate_nginx_config +config = load_config() +generate_nginx_config(config) +print('Nginx config generated from saved configuration.') +" +fi + +# Start gunicorn in background +echo "Starting WebUI..." +cd /app +gunicorn --bind 127.0.0.1:5000 --workers 2 --timeout 120 app:app & + +# Start nginx in foreground +echo "Starting Nginx..." +exec nginx -g "daemon off;" diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..609f83c --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,41 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + sendfile on; + keepalive_timeout 65; + + # WebUI / Admin interface + server { + listen ${WEBUI_PORT} ssl default_server; + server_name _; + + ssl_certificate /certs/server.crt; + ssl_certificate_key /certs/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + # Include dynamic proxy configurations + include /etc/nginx/conf.d/*.conf; +}