From 035534e4f2729d0e2dece2db6050d4502775aeaa Mon Sep 17 00:00:00 2001 From: andrey271192 Date: Tue, 12 May 2026 06:37:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20=D0=B8=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/password — смена пароля админа с проверкой текущего, атомарно переписывает /etc/kaskad/webui.env и hot-reload WEB_PASS в памяти (рестарт не нужен) - GET /api/auth/whoami — текущий логин (показываем в шапке) - GET /api/logout — 401 с уникальным realm → браузер сбрасывает кеш Basic Auth - UI: кнопки 🔑 Пароль и ↪ Выход в шапке; модалка смены пароля с двойным вводом и автологаутом после успеха Co-authored-by: Cursor --- webui/app.py | 56 +++++++++++++++++++++++++++++ webui/static/style.css | 26 ++++++++++++++ webui/templates/index.html | 72 +++++++++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/webui/app.py b/webui/app.py index c0bd65c..945b777 100644 --- a/webui/app.py +++ b/webui/app.py @@ -11,6 +11,7 @@ from pathlib import Path from flask import Flask, jsonify, render_template, request, Response SERVERS_JSON = Path(os.environ.get("KASKAD_SERVERS_JSON", "/etc/wireguard/ru-servers.json")) +WEBUI_ENV = Path(os.environ.get("KASKAD_WEBUI_ENV", "/etc/kaskad/webui.env")) LOCAL_HOST = os.environ.get("LOCAL_HOST", "ams1") LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1") BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519") @@ -545,6 +546,61 @@ def api_ips_clear(): return jsonify(results=results) +@app.route("/api/auth/whoami") +@require_auth +def api_whoami(): + return jsonify(user=WEB_USER) + + +@app.route("/api/auth/password", methods=["POST"]) +@require_auth +def api_change_password(): + """Сменить пароль администратора. + + Перезаписывает строку KASKAD_WEB_PASS=... в /etc/kaskad/webui.env и + обновляет переменную в памяти процесса, чтобы НЕ требовался рестарт. + """ + global WEB_PASS + body = request.json or {} + current = (body.get("current") or "").strip() + new = (body.get("new") or "").strip() + if not new or len(new) < 8: + return jsonify(error="новый пароль должен быть не короче 8 символов"), 400 + if current != WEB_PASS: + return jsonify(error="текущий пароль неверный"), 403 + if new == current: + return jsonify(error="новый пароль совпадает со старым"), 400 + if not WEBUI_ENV.exists(): + return jsonify(error=f"нет файла {WEBUI_ENV} — сменить пароль вручную невозможно"), 500 + + try: + lines = WEBUI_ENV.read_text().splitlines() + found = False + for i, line in enumerate(lines): + if line.startswith("KASKAD_WEB_PASS="): + lines[i] = f"KASKAD_WEB_PASS={new}" + found = True + break + if not found: + lines.append(f"KASKAD_WEB_PASS={new}") + WEBUI_ENV.write_text("\n".join(lines) + "\n") + WEBUI_ENV.chmod(0o600) + except Exception as e: + return jsonify(error=f"запись {WEBUI_ENV}: {e}"), 500 + + WEB_PASS = new + return jsonify(ok=True) + + +@app.route("/api/logout") +def api_logout(): + """Возвращаем 401 с новым realm — браузер сбрасывает кеш Basic Auth.""" + return Response( + "logged out", 401, + {"WWW-Authenticate": f'Basic realm="kaskad-logout-{os.urandom(4).hex()}"'}, + ) + + @app.route("/") @require_auth def index(): diff --git a/webui/static/style.css b/webui/static/style.css index 8842451..802225b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -115,6 +115,32 @@ form textarea { grid-column: span 2; } z-index: 100; } +header .spacer { flex: 1; } + +[x-cloak] { display: none !important; } + +.modal { + position: fixed; inset: 0; + background: rgba(0,0,0,0.65); + display: flex; align-items: center; justify-content: center; + z-index: 200; +} +.modal-body { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 24px; + width: min(420px, 92vw); + box-shadow: 0 12px 40px rgba(0,0,0,0.6); +} +.modal-body h2 { margin-top: 0; } +.modal-body form { display: grid; grid-template-columns: 1fr; gap: 10px; } +.modal-body form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8b949e; } +.modal-actions { + display: flex; gap: 8px; justify-content: flex-end; + margin-top: 8px; +} + footer { margin-top: 60px; padding: 20px 0; border-top: 1px solid #21262d; diff --git a/webui/templates/index.html b/webui/templates/index.html index 5ea1024..b602b4b 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -13,8 +13,37 @@ загружаю… +
+ + + + + +

Состояние туннелей

@@ -203,11 +232,14 @@ return { state: {ru_servers:[], ams_servers:[], status:[], extra_ips:[], base_ips:[], domains:{}}, loading: false, error: '', toast: '', + user: '', + settingsOpen: false, domainFilter: '', ipFilter: '', forms: { ru: {user:'root', ssh_port:22, listen_port:1939, priority:3}, ams: {user:'root', ssh_port:22, xray_iface:'amn0'}, domains: '', ips: '', + pwd: {current:'', new:'', confirm:''}, }, async req(method, url, body) { @@ -224,7 +256,45 @@ finally { this.loading = false; } }, flash(msg) { this.toast = msg; setTimeout(()=>this.toast='', 3500); }, - async load() { try { this.state = await this.req('GET','/api/state'); } catch{} }, + async load() { + try { this.state = await this.req('GET','/api/state'); } catch{} + try { const w = await this.req('GET','/api/auth/whoami'); this.user = w.user || ''; } catch{} + }, + + openSettings() { + this.forms.pwd = {current:'', new:'', confirm:''}; + this.settingsOpen = true; + }, + + async changePassword() { + if (this.forms.pwd.new !== this.forms.pwd.confirm) { + this.error = 'новый пароль и подтверждение не совпадают'; + return; + } + try { + await this.req('POST','/api/auth/password', { + current: this.forms.pwd.current, + new: this.forms.pwd.new, + }); + } catch { return; } + this.settingsOpen = false; + this.flash('Пароль изменён. Сейчас браузер попросит новый.'); + setTimeout(() => this.logout(), 1200); + }, + + async logout() { + // 1) бьём бэк бракованными credentials — он отвечает 401 с новым realm, + // браузер сбрасывает кешированный Basic Auth + try { + await fetch('/api/logout', { + headers: { 'Authorization': 'Basic ' + btoa('logout:' + Math.random()) }, + cache: 'no-store', + credentials: 'omit', + }); + } catch {} + // 2) редиректим на корень с уникальным query — браузер заново спросит логин/пароль + window.location.href = '/?_=' + Date.now(); + }, fmtAge(s) { if (s == null) return '—';