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 base64
import io import io
import os import os
import secrets
import sqlite3 import sqlite3
import subprocess import subprocess
import time import time
from datetime import datetime from datetime import datetime
import qrcode 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 = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET", "dev-secret") app.secret_key = os.environ.get("APP_SECRET", "dev-secret")
@@ -59,6 +60,10 @@ def ensure_schema():
conn.commit() conn.commit()
# Run once per worker process on import
ensure_schema()
def run(cmd): def run(cmd):
return subprocess.check_output(cmd, text=True).strip() 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 @app.before_request
def _auth(): def _auth():
if request.path.startswith("/static/"): if request.path.startswith("/static/"):
@@ -218,8 +232,13 @@ def _auth():
@app.before_request @app.before_request
def _schema(): def _csrf_check():
ensure_schema() 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("/") @app.route("/")
@@ -253,6 +272,7 @@ def index():
"rx": bytes_h(rt.get("rx_bytes", 0)), "rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)), "tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online" if is_online else "offline", "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)), "rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)), "tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online" if is_online else "offline", "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() client_priv, client_pub, client_psk = gen_keypair_psk()
cmd = [ cmd = [
"/usr/local/sbin/wg-peerctl", "sudo", "/usr/local/sbin/wg-peerctl",
"add", "add",
"--client-name", "--client-name", name,
name, "--client-public-key", client_pub,
"--client-public-key", "--client-preshared-key", client_psk,
client_pub, "--persistent-keepalive", "25",
"--client-preshared-key",
client_psk,
"--persistent-keepalive",
"25",
] ]
if routes: if routes:
cmd += ["--client-routes", 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") @app.post("/peers/<int:peer_id>/disable")
def peer_disable(peer_id: int): def peer_disable(peer_id: int):
with db_conn() as conn: with db_conn() as conn:
@@ -428,7 +466,7 @@ def peer_disable(peer_id: int):
return redirect(url_for("index")) return redirect(url_for("index"))
try: 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: except subprocess.CalledProcessError as e:
flash(f"Не удалось отключить peer: {e}", "error") flash(f"Не удалось отключить peer: {e}", "error")
return redirect(url_for("index")) return redirect(url_for("index"))
@@ -462,18 +500,13 @@ def peer_enable(peer_id: int):
return redirect(url_for("index")) return redirect(url_for("index"))
cmd = [ cmd = [
"/usr/local/sbin/wg-peerctl", "sudo", "/usr/local/sbin/wg-peerctl",
"add", "add",
"--client-name", "--client-name", name,
name, "--client-public-key", pk,
"--client-public-key", "--client-address", addr,
pk, "--client-preshared-key", psk,
"--client-address", "--persistent-keepalive", "25",
addr,
"--client-preshared-key",
psk,
"--persistent-keepalive",
"25",
] ]
if routes: if routes:
cmd += ["--client-routes", routes] cmd += ["--client-routes", routes]
@@ -506,7 +539,7 @@ def peer_delete(peer_id: int):
pk = item.get("public_key", "") pk = item.get("public_key", "")
if pk: if pk:
try: 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: except Exception:
pass pass
@@ -526,7 +559,7 @@ def peer_delete_by_key():
return redirect(url_for("index")) return redirect(url_for("index"))
try: 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: except Exception:
pass pass
@@ -547,7 +580,7 @@ def scripts():
"apply_wg": "wg syncconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)", "apply_wg": "wg syncconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)",
} }
paths = [ paths = [
"/usr/local/sbin/wg-peerctl", "sudo", "/usr/local/sbin/wg-peerctl",
"/etc/wireguard/wg0.conf", "/etc/wireguard/wg0.conf",
"/etc/wireguard/wg-meta.env", "/etc/wireguard/wg-meta.env",
"/var/log/wireguard-server-install.log", "/var/log/wireguard-server-install.log",
+336 -26
View File
@@ -1,28 +1,338 @@
/* ─── Reset & base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #f3f8f4; --bg: #f5f6fa;
--fg: #12261a; --bg2: #ffffff;
--brand: #145a32; --bg3: #f0f2f7;
--muted: #5c7463; --border: #e2e5ee;
--card: #ffffff; --accent: #3b6ef6;
--accent-h: #2955d4;
--green: #16a34a;
--green-bg: #dcfce7;
--red: #dc2626;
--red-bg: #fee2e2;
--yellow: #d97706;
--yellow-bg: #fef3c7;
--text: #111827;
--text-muted:#6b7280;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.05);
--sidebar-w: 220px;
--radius: 10px;
--font: 'Inter', system-ui, -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
display: flex;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
/* ─── Sidebar ───────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 10;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 18px;
font-size: 16px;
font-weight: 700;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.sidebar-logo svg { color: var(--accent); flex-shrink: 0; }
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 12px 10px;
gap: 2px;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.sidebar-nav a:hover { background: var(--bg3); color: var(--text); }
.sidebar-nav a.active { background: #eff4ff; color: var(--accent); }
.sidebar-nav a.active svg { color: var(--accent); }
/* ─── Layout ────────────────────────────────────────────────── */
.layout { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; }
main { padding: 28px 32px; flex: 1; max-width: 1400px; width: 100%; }
/* ─── Page header ───────────────────────────────────────────── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.page-header h2 { font-size: 22px; font-weight: 700; color: var(--text); }
.meta-line { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.meta-tag {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
}
/* ─── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.card h3 { font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 14px; }
/* ─── Alerts ─────────────────────────────────────────────────── */
.alert {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 14px;
font-size: 13.5px;
gap: 12px;
border: 1px solid transparent;
}
.alert button { background: none; border: none; cursor: pointer; color: inherit; opacity: 0.5; font-size: 14px; padding: 0; }
.alert.ok { background: var(--green-bg); color: #15803d; border-color: #bbf7d0; }
.alert.error { background: var(--red-bg); color: #b91c1c; border-color: #fecaca; }
.alert.info { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
/* ─── Table ──────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
text-align: left;
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
background: var(--bg3);
}
thead th:first-child { border-radius: 6px 0 0 6px; }
thead th:last-child { border-radius: 0 6px 6px 0; }
tbody td {
padding: 11px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
color: var(--text);
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: #fafbff; }
tbody tr.row-disabled td { opacity: 0.45; }
.peer-name { font-weight: 600; color: var(--text); }
.mono-sm { font-family: var(--mono); font-size: 12px; }
.text-muted { color: var(--text-muted); }
.pubkey { font-family: var(--mono); font-size: 11px; color: var(--text-muted); cursor: default; }
.empty { text-align: center; padding: 40px !important; color: var(--text-muted); }
.empty a { color: var(--accent); text-decoration: none; }
/* ─── Badges ─────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.badge.online { background: var(--green-bg); color: var(--green); }
.badge.online .dot { background: var(--green); box-shadow: 0 0 4px var(--green); }
.badge.offline { background: var(--bg3); color: var(--text-muted); }
.badge.offline .dot { background: #9ca3af; }
/* ─── Buttons ────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg2);
color: var(--text);
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
white-space: nowrap;
font-family: var(--font);
line-height: 1;
box-shadow: var(--shadow);
}
.btn:hover { background: var(--bg3); border-color: #c4cad8; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 3px rgba(59,110,246,.3); }
.btn-primary:hover { background: var(--accent-h); border-color: var(--accent-h); }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; box-shadow: none; }
.btn-danger { background: var(--red-bg); border-color: #fca5a5; color: var(--red); box-shadow: none; }
.btn-danger:hover { background: #fecaca; border-color: #f87171; }
.btn-warn { background: var(--yellow-bg); border-color: #fcd34d; color: var(--yellow); box-shadow: none; }
.btn-warn:hover { background: #fde68a; }
.btn-ok { background: var(--green-bg); border-color: #86efac; color: var(--green); box-shadow: none; }
.btn-ok:hover { background: #bbf7d0; }
.actions { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
/* ─── Forms ──────────────────────────────────────────────────── */
.form-card { max-width: 540px; }
.form-group { margin-bottom: 18px; display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 13px; font-weight: 600; color: var(--text); }
.form-group input[type="text"] {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
font-size: 13.5px;
color: var(--text);
font-family: var(--font);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: var(--shadow);
}
.form-group input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(59,110,246,.12); }
.form-group input[type="text"]::placeholder { color: #9ca3af; }
.form-group small { font-size: 12px; color: var(--text-muted); }
.radio-group { display: flex; flex-direction: column; gap: 8px; }
.radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13.5px; color: var(--text); }
.radio-label input[type="radio"] { accent-color: var(--accent); }
.form-actions { display: flex; gap: 8px; margin-top: 24px; }
/* ─── Peer detail ────────────────────────────────────────────── */
.peer-detail-grid { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: start; }
@media (max-width: 768px) { .peer-detail-grid { grid-template-columns: 1fr; } }
.qr-card { text-align: center; width: fit-content; }
.qr-img {
width: 220px;
height: 220px;
border-radius: 8px;
display: block;
margin: 0 auto 10px;
border: 1px solid var(--border);
}
.conf-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.conf-header h3 { margin-bottom: 0; }
.conf-block {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
font-family: var(--mono);
font-size: 12.5px;
color: #1e40af;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
}
.pubkey-block { margin-top: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pubkey-block .label { font-size: 12px; color: var(--text-muted); }
/* ─── Scripts ────────────────────────────────────────────────── */
.script-list { display: flex; flex-direction: column; gap: 14px; }
.script-item { display: flex; flex-direction: column; gap: 6px; }
.script-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.script-cmd-wrap { display: flex; align-items: flex-start; gap: 8px; }
.script-cmd {
flex: 1;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 7px;
padding: 10px 14px;
font-family: var(--mono);
font-size: 12px;
color: #1e40af;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
.path-list { list-style: none; display: flex; flex-direction: column; gap: 6px; }
.path-list li {
padding: 7px 12px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 12.5px;
font-family: var(--mono);
} }
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: radial-gradient(circle at 10% 10%, #d8ead9, var(--bg)); color: var(--fg); }
.top { display: flex; justify-content: space-between; align-items: center; padding: 16px 22px; background: #e7f4e9; border-bottom: 1px solid #c7decb; }
.top h1 { margin: 0; font-size: 24px; }
.top nav a { margin-right: 14px; color: var(--brand); text-decoration: none; font-weight: 600; }
main { padding: 22px; }
.card { background: var(--card); padding: 16px; border-radius: 12px; border: 1px solid #d7e7da; display: grid; gap: 10px; max-width: 640px; }
label { display: grid; gap: 6px; }
input, select, button { padding: 10px; border-radius: 10px; border: 1px solid #bad2bf; font-size: 14px; }
button { background: var(--brand); color: #fff; border: 0; font-weight: 700; cursor: pointer; }
button.danger { background: #b42318; }
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #d7e7da; }
th, td { border-bottom: 1px solid #e0ece2; padding: 8px; font-size: 13px; text-align: left; vertical-align: top; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.badge.online { background: #d8f0df; color: #115f33; }
.badge.offline { background: #f2e7e7; color: #8a2e2e; }
pre { background: #0e1b12; color: #c8f6d8; padding: 10px; border-radius: 10px; overflow: auto; }
.alert { padding: 10px; border-radius: 8px; margin-bottom: 10px; }
.alert.error { background: #ffe0e0; color: #8a2020; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
+25 -8
View File
@@ -7,23 +7,40 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head> </head>
<body> <body>
<header class="top"> <aside class="sidebar">
<h1>WG Admin</h1> <div class="sidebar-logo">
<nav> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
<a href="{{ url_for('index') }}">Клиенты</a> <span>WG Admin</span>
<a href="{{ url_for('new_peer') }}">Добавить peer</a> </div>
<a href="{{ url_for('scripts') }}">Скрипты</a> <nav class="sidebar-nav">
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Клиенты
</a>
<a href="{{ url_for('new_peer') }}" class="{{ 'active' if request.endpoint == 'new_peer' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Добавить peer
</a>
<a href="{{ url_for('scripts') }}" class="{{ 'active' if request.endpoint == 'scripts' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Скрипты
</a>
</nav> </nav>
</header> </aside>
<div class="layout">
<main> <main>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert {{category}}">{{message}}</div> <div class="alert {{ category }}">
<span>{{ message }}</span>
<button onclick="this.parentElement.remove()"></button>
</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div>
</body> </body>
</html> </html>
+66 -20
View File
@@ -1,41 +1,87 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<h2>Клиенты</h2> <div class="page-header">
<p>Интерфейс: <b>{{ meta.get('WG_INTERFACE','wg0') }}</b> | Сеть: <b>{{ meta.get('WG_NETWORK','-') }}</b> | Endpoint: <b>{{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}</b></p> <div>
<table> <h2>Клиенты</h2>
<p class="meta-line">
<span class="meta-tag">{{ meta.get('WG_INTERFACE','wg0') }}</span>
<span class="meta-tag">{{ meta.get('WG_NETWORK','-') }}</span>
<span class="meta-tag">{{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}</span>
</p>
</div>
<a href="{{ url_for('new_peer') }}" class="btn btn-primary">+ Добавить peer</a>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead> <thead>
<tr> <tr>
<th>Имя</th><th>Статус</th><th>IP</th><th>Роуты</th><th>AllowedIPs</th><th>Endpoint</th><th>Handshake</th><th>RX</th><th>TX</th><th>PubKey</th><th>Действие</th> <th>Имя</th>
<th>Статус</th>
<th>IP</th>
<th>Роуты</th>
<th>Endpoint</th>
<th>Handshake</th>
<th>RX / TX</th>
<th>Публичный ключ</th>
<th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for p in peers %} {% for p in peers %}
<tr> <tr class="{{ 'row-disabled' if not p.enabled else '' }}">
<td>{{ p.name }}</td> <td><span class="peer-name">{{ p.name }}</span></td>
<td><span class="badge {{ p.status }}">{{ p.status }}</span></td>
<td>{{ p.client_address }}</td>
<td>{{ p.routes }}</td>
<td>{{ p.allowed_ips }}</td>
<td>{{ p.endpoint }}</td>
<td>{{ p.latest_handshake }}</td>
<td>{{ p.rx }}</td>
<td>{{ p.tx }}</td>
<td class="mono">{{ p.public_key }}</td>
<td> <td>
<span class="badge {{ p.status }}">
<i class="dot"></i>{{ p.status }}
</span>
</td>
<td class="mono-sm">{{ p.client_address }}</td>
<td class="mono-sm text-muted">{{ p.routes }}</td>
<td class="mono-sm text-muted">{{ p.endpoint }}</td>
<td class="text-muted">{{ p.latest_handshake }}</td>
<td class="text-muted">{{ p.rx }} / {{ p.tx }}</td>
<td><span class="pubkey" title="{{ p.public_key }}">{{ p.public_key[:20] }}…</span></td>
<td>
<div class="actions">
{% if p.id %} {% if p.id %}
<a href="{{ url_for('peer_view', peer_id=p.id) }}">QR/Config</a> <a href="{{ url_for('peer_view', peer_id=p.id) }}" class="btn btn-sm">QR</a>
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer?')"> <a href="{{ url_for('peer_download', peer_id=p.id) }}" class="btn btn-sm">↓ .conf</a>
<button type="submit" class="danger">Удалить</button>
{% if p.enabled %}
<form method="post" action="{{ url_for('peer_disable', peer_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-warn">Откл</button>
</form>
{% else %}
<form method="post" action="{{ url_for('peer_enable', peer_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-ok">Вкл</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer {{ p.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form> </form>
{% else %} {% else %}
<form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')"> <form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="public_key" value="{{ p.public_key }}" /> <input type="hidden" name="public_key" value="{{ p.public_key }}" />
<button type="submit" class="danger">Удалить</button> <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form> </form>
{% endif %} {% endif %}
</div>
</td> </td>
</tr> </tr>
{% else %}
<tr>
<td colspan="9" class="empty">Пиров нет. <a href="{{ url_for('new_peer') }}">Добавить первый</a></td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
{% endblock %} {% endblock %}
+48 -39
View File
@@ -1,46 +1,55 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<h2>Новый peer</h2> <div class="page-header">
<form method="post" class="card"> <h2>Новый peer</h2>
<label>Имя клиента </div>
<input name="name" required placeholder="astra" />
<div class="card form-card">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Имя клиента</label>
<input type="text" name="name" placeholder="например: phone-ruslan" autofocus required />
</div>
<div class="form-group">
<label>Режим</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="mode" value="full" checked onchange="toggleRoutes(this)">
<span>Полный туннель (0.0.0.0/0)</span>
</label> </label>
<label>Режим <label class="radio-label">
<select name="mode" id="mode"> <input type="radio" name="mode" value="split" onchange="toggleRoutes(this)">
<option value="full">full (весь трафик через VPN)</option> <span>Split-tunnel (только нужные сети)</span>
<option value="split">split (только выбранные сети)</option>
</select>
</label> </label>
<label>AllowedIPs (для split) </div>
<input </div>
id="allowed_ips"
name="allowed_ips" <div class="form-group" id="allowed-ips-group" style="display:none">
placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}" <label>AllowedIPs для клиента</label>
data-default="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}" <input type="text" name="allowed_ips" placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}" />
/> <small>Через запятую. Оставьте пустым — подставится сеть WG.</small>
</label> </div>
<label>Сети за клиентом (роуты)
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" /> <div class="form-group">
</label> <label>Дополнительные роуты (advertised)</label>
<button type="submit">Создать</button> <input type="text" name="routes" placeholder="192.168.1.0/24, 10.0.0.0/8" />
</form> <small>Сети, которые клиент анонсирует другим участникам. Необязательно.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Создать peer</button>
<a href="{{ url_for('index') }}" class="btn">Отмена</a>
</div>
</form>
</div>
<script> <script>
(() => { function toggleRoutes(el) {
const mode = document.getElementById("mode"); document.getElementById('allowed-ips-group').style.display =
const allowed = document.getElementById("allowed_ips"); el.value === 'split' ? 'block' : 'none';
const def = allowed.dataset.default || "10.66.66.0/24"; }
function syncAllowed() {
if (mode.value === "split") {
if (!allowed.value.trim()) allowed.value = def;
allowed.readOnly = false;
} else {
allowed.readOnly = true;
}
}
mode.addEventListener("change", syncAllowed);
syncAllowed();
})();
</script> </script>
{% endblock %} {% endblock %}
+21 -10
View File
@@ -1,16 +1,27 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<h2>Peer создан: {{ name }}</h2> <div class="page-header">
<p>PublicKey: <span class="mono">{{ public_key }}</span></p> <h2>{{ name }}</h2>
<p><a href="data:text/plain;charset=utf-8,{{ client_conf | urlencode }}" download="{{ name }}.conf">Скачать {{ name }}.conf</a></p> <a href="{{ url_for('index') }}" class="btn">← Назад</a>
<div class="grid2"> </div>
<div>
<h3>QR</h3> <div class="peer-detail-grid">
<img alt="QR" src="data:image/png;base64,{{ qr_b64 }}" /> <div class="card qr-card">
<h3>QR-код</h3>
<img src="data:image/png;base64,{{ qr_b64 }}" alt="QR" class="qr-img" />
<p class="text-muted">Отсканируйте в приложении WireGuard</p>
</div>
<div class="card conf-card">
<div class="conf-header">
<h3>Конфигурация</h3>
<a href="{{ url_for('peer_download', peer_id=request.view_args.get('peer_id', 0)) }}" class="btn btn-sm">↓ Скачать .conf</a>
</div>
<pre class="conf-block">{{ client_conf }}</pre>
<div class="pubkey-block">
<span class="label">Публичный ключ:</span>
<span class="mono-sm">{{ public_key }}</span>
</div> </div>
<div>
<h3>Client config</h3>
<pre>{{ client_conf }}</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+36 -12
View File
@@ -1,15 +1,39 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<h2>Скрипты и команды</h2> <div class="page-header">
<h3>Команды</h3> <h2>Скрипты и пути</h2>
{% for k, v in commands.items() %} </div>
<p><b>{{ k }}</b></p>
<pre>{{ v }}</pre> <div class="card">
{% endfor %} <h3>Команды</h3>
<h3>Важные пути</h3> <div class="script-list">
<ul> {% for key, cmd in commands.items() %}
{% for p in paths %} <div class="script-item">
<li><code>{{ p }}</code></li> <div class="script-label">{{ key }}</div>
{% endfor %} <div class="script-cmd-wrap">
</ul> <pre class="script-cmd">{{ cmd }}</pre>
<button class="btn btn-sm copy-btn" onclick="copyText(this, '{{ cmd | replace("'", "\\'") }}')">Копировать</button>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<h3>Важные пути</h3>
<ul class="path-list">
{% for path in paths %}
<li class="mono-sm">{{ path }}</li>
{% endfor %}
</ul>
</div>
<script>
function copyText(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Скопировано!';
setTimeout(() => btn.textContent = 'Копировать', 2000);
});
}
</script>
{% endblock %} {% endblock %}