feat(gui): security hardening, UI overhaul, light theme

- CSRF protection on all POST forms (session token)
- ensure_schema() moved to module-level, removed from before_request
- gunicorn now binds to 127.0.0.1 only, runs as unprivileged user wgadmin
- nginx reverse proxy with HTTPS (Let's Encrypt, wg.4mont.ru)
- HTTP → HTTPS redirect before Basic Auth prompt
- Auth moved to nginx level (auth_basic), wg-peerctl called via sudo
- ufw firewall: only 22/80/443/51820 open
- fail2ban: SSH + nginx (5 attempts → 1h ban)
- Add Enable/Disable toggle buttons in peer table
- Add .conf file download route
- Light theme: white background, blue accent, subtle shadows
- Modern sidebar layout, styled badges, responsive forms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 10:10:19 +03:00
parent 530e93c1df
commit 904582e7fa
7 changed files with 620 additions and 170 deletions
+60 -27
View File
@@ -2,13 +2,14 @@
import base64
import io
import os
import secrets
import sqlite3
import subprocess
import time
from datetime import datetime
import qrcode
from flask import Flask, redirect, render_template, request, url_for, flash, Response
from flask import Flask, redirect, render_template, request, url_for, flash, Response, session, send_file
app = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET", "dev-secret")
@@ -59,6 +60,10 @@ def ensure_schema():
conn.commit()
# Run once per worker process on import
ensure_schema()
def run(cmd):
return subprocess.check_output(cmd, text=True).strip()
@@ -203,6 +208,15 @@ def _unauthorized():
)
def _get_csrf_token():
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
return session["csrf_token"]
app.jinja_env.globals["csrf_token"] = _get_csrf_token
@app.before_request
def _auth():
if request.path.startswith("/static/"):
@@ -218,8 +232,13 @@ def _auth():
@app.before_request
def _schema():
ensure_schema()
def _csrf_check():
if request.method != "POST":
return None
token = request.form.get("csrf_token")
if not token or token != session.get("csrf_token"):
return Response("CSRF token invalid", 403)
return None
@app.route("/")
@@ -253,6 +272,7 @@ def index():
"rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online" if is_online else "offline",
"enabled": row.get("enabled", 1),
}
)
@@ -292,6 +312,7 @@ def index():
"rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online" if is_online else "offline",
"enabled": 1,
}
)
@@ -321,16 +342,12 @@ def new_peer():
client_priv, client_pub, client_psk = gen_keypair_psk()
cmd = [
"/usr/local/sbin/wg-peerctl",
"sudo", "/usr/local/sbin/wg-peerctl",
"add",
"--client-name",
name,
"--client-public-key",
client_pub,
"--client-preshared-key",
client_psk,
"--persistent-keepalive",
"25",
"--client-name", name,
"--client-public-key", client_pub,
"--client-preshared-key", client_psk,
"--persistent-keepalive", "25",
]
if routes:
cmd += ["--client-routes", routes]
@@ -411,6 +428,27 @@ def peer_view(peer_id: int):
)
@app.route("/peers/<int:peer_id>/download")
def peer_download(peer_id: int):
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
row = cur.fetchone()
if not row:
flash("Клиент не найден", "error")
return redirect(url_for("index"))
item = dict(row)
conf = item.get("client_conf") or ""
if not conf:
flash("Для этого клиента не найден сохраненный конфиг", "error")
return redirect(url_for("index"))
name = item.get("name", "peer")
buf = io.BytesIO(conf.encode("utf-8"))
return send_file(buf, as_attachment=True, download_name=f"{name}.conf", mimetype="text/plain")
@app.post("/peers/<int:peer_id>/disable")
def peer_disable(peer_id: int):
with db_conn() as conn:
@@ -428,7 +466,7 @@ def peer_disable(peer_id: int):
return redirect(url_for("index"))
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
run(["sudo", "/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except subprocess.CalledProcessError as e:
flash(f"Не удалось отключить peer: {e}", "error")
return redirect(url_for("index"))
@@ -462,18 +500,13 @@ def peer_enable(peer_id: int):
return redirect(url_for("index"))
cmd = [
"/usr/local/sbin/wg-peerctl",
"sudo", "/usr/local/sbin/wg-peerctl",
"add",
"--client-name",
name,
"--client-public-key",
pk,
"--client-address",
addr,
"--client-preshared-key",
psk,
"--persistent-keepalive",
"25",
"--client-name", name,
"--client-public-key", pk,
"--client-address", addr,
"--client-preshared-key", psk,
"--persistent-keepalive", "25",
]
if routes:
cmd += ["--client-routes", routes]
@@ -506,7 +539,7 @@ def peer_delete(peer_id: int):
pk = item.get("public_key", "")
if pk:
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
run(["sudo", "/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except Exception:
pass
@@ -526,7 +559,7 @@ def peer_delete_by_key():
return redirect(url_for("index"))
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
run(["sudo", "/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except Exception:
pass
@@ -547,7 +580,7 @@ def scripts():
"apply_wg": "wg syncconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)",
}
paths = [
"/usr/local/sbin/wg-peerctl",
"sudo", "/usr/local/sbin/wg-peerctl",
"/etc/wireguard/wg0.conf",
"/etc/wireguard/wg-meta.env",
"/var/log/wireguard-server-install.log",