diff --git a/gui/app.py b/gui/app.py index 1180ac3..5e0300a 100644 --- a/gui/app.py +++ b/gui/app.py @@ -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//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//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", diff --git a/gui/static/style.css b/gui/static/style.css index bd22288..f3eefab 100644 --- a/gui/static/style.css +++ b/gui/static/style.css @@ -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; } } diff --git a/gui/templates/base.html b/gui/templates/base.html index 7ef3884..1518a7b 100644 --- a/gui/templates/base.html +++ b/gui/templates/base.html @@ -7,23 +7,40 @@ -
-

WG Admin

-
-
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{message}}
- {% endfor %} - {% endif %} - {% endwith %} - {% block content %}{% endblock %} -
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
diff --git a/gui/templates/index.html b/gui/templates/index.html index 5a93509..eecbba1 100644 --- a/gui/templates/index.html +++ b/gui/templates/index.html @@ -1,41 +1,87 @@ {% extends 'base.html' %} {% block content %} -

Клиенты

-

Интерфейс: {{ meta.get('WG_INTERFACE','wg0') }} | Сеть: {{ meta.get('WG_NETWORK','-') }} | Endpoint: {{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}

- - - - - - - - {% for p in peers %} - - - - - - - - - - - - + + + {% endfor %} + +
ИмяСтатусIPРоутыAllowedIPsEndpointHandshakeRXTXPubKeyДействие
{{ p.name }}{{ p.status }}{{ p.client_address }}{{ p.routes }}{{ p.allowed_ips }}{{ p.endpoint }}{{ p.latest_handshake }}{{ p.rx }}{{ p.tx }}{{ p.public_key }} - {% if p.id %} - QR/Config -
- -
+ + +
+
+ + + + + + + + + + + + + + + + {% for p in peers %} + + + + + + + + + + + {% else %} - - - - - {% endif %} - - - {% endfor %} - -
ИмяСтатусIPРоутыEndpointHandshakeRX / TXПубличный ключДействия
{{ p.name }} + + {{ p.status }} + + {{ p.client_address }}{{ p.routes }}{{ p.endpoint }}{{ p.latest_handshake }}{{ p.rx }} / {{ p.tx }}{{ p.public_key[:20] }}… +
+ {% if p.id %} + QR + ↓ .conf + + {% if p.enabled %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ {% else %} +
+ + + +
+ {% endif %} +
+
+
Пиров нет. Добавить первый
+ + {% endblock %} diff --git a/gui/templates/new_peer.html b/gui/templates/new_peer.html index 0371d72..bb0eefb 100644 --- a/gui/templates/new_peer.html +++ b/gui/templates/new_peer.html @@ -1,46 +1,55 @@ {% extends 'base.html' %} {% block content %} -

Новый peer

-
- - - - - -
+ + +
+
+ + +
+ + +
+ +
+ +
+ + +
+
+ + + +
+ + + Сети, которые клиент анонсирует другим участникам. Необязательно. +
+ +
+ + Отмена +
+
+
+ {% endblock %} diff --git a/gui/templates/peer_created.html b/gui/templates/peer_created.html index 4f5576b..c64b39d 100644 --- a/gui/templates/peer_created.html +++ b/gui/templates/peer_created.html @@ -1,16 +1,27 @@ {% extends 'base.html' %} {% block content %} -

Peer создан: {{ name }}

-

PublicKey: {{ public_key }}

-

Скачать {{ name }}.conf

-
-
-

QR

- QR + + +
+
+

QR-код

+ QR +

Отсканируйте в приложении WireGuard

-
-

Client config

-
{{ client_conf }}
+ +
+
+

Конфигурация

+ ↓ Скачать .conf +
+
{{ client_conf }}
+
+ Публичный ключ: + {{ public_key }} +
{% endblock %} diff --git a/gui/templates/scripts.html b/gui/templates/scripts.html index 99d5374..1240d2f 100644 --- a/gui/templates/scripts.html +++ b/gui/templates/scripts.html @@ -1,15 +1,39 @@ {% extends 'base.html' %} {% block content %} -

Скрипты и команды

-

Команды

-{% for k, v in commands.items() %} -

{{ k }}

-
{{ v }}
-{% endfor %} -

Важные пути

-
    -{% for p in paths %} -
  • {{ p }}
  • -{% endfor %} -
+ + +
+

Команды

+
+ {% for key, cmd in commands.items() %} +
+
{{ key }}
+
+
{{ cmd }}
+ +
+
+ {% endfor %} +
+
+ +
+

Важные пути

+
    + {% for path in paths %} +
  • {{ path }}
  • + {% endfor %} +
+
+ + {% endblock %}