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:
+60
-27
@@ -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",
|
||||
|
||||
+336
-26
@@ -1,28 +1,338 @@
|
||||
/* ─── Reset & base ─────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f3f8f4;
|
||||
--fg: #12261a;
|
||||
--brand: #145a32;
|
||||
--muted: #5c7463;
|
||||
--card: #ffffff;
|
||||
--bg: #f5f6fa;
|
||||
--bg2: #ffffff;
|
||||
--bg3: #f0f2f7;
|
||||
--border: #e2e5ee;
|
||||
--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
@@ -7,23 +7,40 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>WG Admin</h1>
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">Клиенты</a>
|
||||
<a href="{{ url_for('new_peer') }}">Добавить peer</a>
|
||||
<a href="{{ url_for('scripts') }}">Скрипты</a>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<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>
|
||||
<span>WG Admin</span>
|
||||
</div>
|
||||
<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>
|
||||
</header>
|
||||
</aside>
|
||||
<div class="layout">
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if 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 %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+66
-20
@@ -1,41 +1,87 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Клиенты</h2>
|
||||
<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>
|
||||
<table>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in peers %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</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>
|
||||
<tr class="{{ 'row-disabled' if not p.enabled else '' }}">
|
||||
<td><span class="peer-name">{{ p.name }}</span></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 %}
|
||||
<a href="{{ url_for('peer_view', peer_id=p.id) }}">QR/Config</a>
|
||||
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
|
||||
<button type="submit" class="danger">Удалить</button>
|
||||
<a href="{{ url_for('peer_view', peer_id=p.id) }}" class="btn btn-sm">QR</a>
|
||||
<a href="{{ url_for('peer_download', peer_id=p.id) }}" class="btn btn-sm">↓ .conf</a>
|
||||
|
||||
{% 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>
|
||||
{% else %}
|
||||
<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 }}" />
|
||||
<button type="submit" class="danger">Удалить</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" class="empty">Пиров нет. <a href="{{ url_for('new_peer') }}">Добавить первый</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+48
-39
@@ -1,46 +1,55 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Новый peer</h2>
|
||||
<form method="post" class="card">
|
||||
<label>Имя клиента
|
||||
<input name="name" required placeholder="astra" />
|
||||
<div class="page-header">
|
||||
<h2>Новый peer</h2>
|
||||
</div>
|
||||
|
||||
<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>Режим
|
||||
<select name="mode" id="mode">
|
||||
<option value="full">full (весь трафик через VPN)</option>
|
||||
<option value="split">split (только выбранные сети)</option>
|
||||
</select>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="mode" value="split" onchange="toggleRoutes(this)">
|
||||
<span>Split-tunnel (только нужные сети)</span>
|
||||
</label>
|
||||
<label>AllowedIPs (для split)
|
||||
<input
|
||||
id="allowed_ips"
|
||||
name="allowed_ips"
|
||||
placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
|
||||
data-default="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
|
||||
/>
|
||||
</label>
|
||||
<label>Сети за клиентом (роуты)
|
||||
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" />
|
||||
</label>
|
||||
<button type="submit">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="allowed-ips-group" style="display:none">
|
||||
<label>AllowedIPs для клиента</label>
|
||||
<input type="text" name="allowed_ips" placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}" />
|
||||
<small>Через запятую. Оставьте пустым — подставится сеть WG.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Дополнительные роуты (advertised)</label>
|
||||
<input type="text" name="routes" placeholder="192.168.1.0/24, 10.0.0.0/8" />
|
||||
<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>
|
||||
(() => {
|
||||
const mode = document.getElementById("mode");
|
||||
const allowed = document.getElementById("allowed_ips");
|
||||
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();
|
||||
})();
|
||||
function toggleRoutes(el) {
|
||||
document.getElementById('allowed-ips-group').style.display =
|
||||
el.value === 'split' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Peer создан: {{ name }}</h2>
|
||||
<p>PublicKey: <span class="mono">{{ public_key }}</span></p>
|
||||
<p><a href="data:text/plain;charset=utf-8,{{ client_conf | urlencode }}" download="{{ name }}.conf">Скачать {{ name }}.conf</a></p>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<h3>QR</h3>
|
||||
<img alt="QR" src="data:image/png;base64,{{ qr_b64 }}" />
|
||||
<div class="page-header">
|
||||
<h2>{{ name }}</h2>
|
||||
<a href="{{ url_for('index') }}" class="btn">← Назад</a>
|
||||
</div>
|
||||
|
||||
<div class="peer-detail-grid">
|
||||
<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>
|
||||
<h3>Client config</h3>
|
||||
<pre>{{ client_conf }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+36
-12
@@ -1,15 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Скрипты и команды</h2>
|
||||
<h3>Команды</h3>
|
||||
{% for k, v in commands.items() %}
|
||||
<p><b>{{ k }}</b></p>
|
||||
<pre>{{ v }}</pre>
|
||||
{% endfor %}
|
||||
<h3>Важные пути</h3>
|
||||
<ul>
|
||||
{% for p in paths %}
|
||||
<li><code>{{ p }}</code></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="page-header">
|
||||
<h2>Скрипты и пути</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Команды</h3>
|
||||
<div class="script-list">
|
||||
{% for key, cmd in commands.items() %}
|
||||
<div class="script-item">
|
||||
<div class="script-label">{{ key }}</div>
|
||||
<div class="script-cmd-wrap">
|
||||
<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 %}
|
||||
|
||||
Reference in New Issue
Block a user