mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-20 13:49:56 +00:00
feat(webui): смена пароля и кнопка выхода
- 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 <cursoragent@cursor.com>
This commit is contained in:
56
webui/app.py
56
webui/app.py
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from flask import Flask, jsonify, render_template, request, Response
|
from flask import Flask, jsonify, render_template, request, Response
|
||||||
|
|
||||||
SERVERS_JSON = Path(os.environ.get("KASKAD_SERVERS_JSON", "/etc/wireguard/ru-servers.json"))
|
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_HOST = os.environ.get("LOCAL_HOST", "ams1")
|
||||||
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
|
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
|
||||||
BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519")
|
BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519")
|
||||||
@@ -545,6 +546,61 @@ def api_ips_clear():
|
|||||||
return jsonify(results=results)
|
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("/")
|
@app.route("/")
|
||||||
@require_auth
|
@require_auth
|
||||||
def index():
|
def index():
|
||||||
|
|||||||
@@ -115,6 +115,32 @@ form textarea { grid-column: span 2; }
|
|||||||
z-index: 100;
|
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 {
|
footer {
|
||||||
margin-top: 60px; padding: 20px 0;
|
margin-top: 60px; padding: 20px 0;
|
||||||
border-top: 1px solid #21262d;
|
border-top: 1px solid #21262d;
|
||||||
|
|||||||
@@ -13,8 +13,37 @@
|
|||||||
<button @click="load()" :disabled="loading">⟳ Обновить</button>
|
<button @click="load()" :disabled="loading">⟳ Обновить</button>
|
||||||
<span class="status" x-show="loading">загружаю…</span>
|
<span class="status" x-show="loading">загружаю…</span>
|
||||||
<span class="status err" x-show="error" x-text="error"></span>
|
<span class="status err" x-show="error" x-text="error"></span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="muted" x-show="user" x-text="'👤 ' + user"></span>
|
||||||
|
<button @click="openSettings()" title="сменить пароль">🔑 Пароль</button>
|
||||||
|
<button class="danger" @click="logout()" title="выйти из WebUI">↪ Выход</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Settings modal -->
|
||||||
|
<div class="modal" x-show="settingsOpen" x-cloak @click.self="settingsOpen=false">
|
||||||
|
<div class="modal-body">
|
||||||
|
<h2>Сменить пароль администратора</h2>
|
||||||
|
<form @submit.prevent="changePassword()">
|
||||||
|
<label>Текущий пароль
|
||||||
|
<input type="password" x-model="forms.pwd.current" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label>Новый пароль (≥ 8 символов)
|
||||||
|
<input type="password" x-model="forms.pwd.new" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>Повторите новый пароль
|
||||||
|
<input type="password" x-model="forms.pwd.confirm" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" @click="settingsOpen=false">отмена</button>
|
||||||
|
<button type="submit" class="danger solid" :disabled="loading">сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:12px">
|
||||||
|
После смены пароля браузер автоматически попросит ввести новый — это нормально.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status: ам. серверы и куда ходят -->
|
<!-- Status: ам. серверы и куда ходят -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Состояние туннелей</h2>
|
<h2>Состояние туннелей</h2>
|
||||||
@@ -203,11 +232,14 @@
|
|||||||
return {
|
return {
|
||||||
state: {ru_servers:[], ams_servers:[], status:[], extra_ips:[], base_ips:[], domains:{}},
|
state: {ru_servers:[], ams_servers:[], status:[], extra_ips:[], base_ips:[], domains:{}},
|
||||||
loading: false, error: '', toast: '',
|
loading: false, error: '', toast: '',
|
||||||
|
user: '',
|
||||||
|
settingsOpen: false,
|
||||||
domainFilter: '', ipFilter: '',
|
domainFilter: '', ipFilter: '',
|
||||||
forms: {
|
forms: {
|
||||||
ru: {user:'root', ssh_port:22, listen_port:1939, priority:3},
|
ru: {user:'root', ssh_port:22, listen_port:1939, priority:3},
|
||||||
ams: {user:'root', ssh_port:22, xray_iface:'amn0'},
|
ams: {user:'root', ssh_port:22, xray_iface:'amn0'},
|
||||||
domains: '', ips: '',
|
domains: '', ips: '',
|
||||||
|
pwd: {current:'', new:'', confirm:''},
|
||||||
},
|
},
|
||||||
|
|
||||||
async req(method, url, body) {
|
async req(method, url, body) {
|
||||||
@@ -224,7 +256,45 @@
|
|||||||
finally { this.loading = false; }
|
finally { this.loading = false; }
|
||||||
},
|
},
|
||||||
flash(msg) { this.toast = msg; setTimeout(()=>this.toast='', 3500); },
|
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) {
|
fmtAge(s) {
|
||||||
if (s == null) return '—';
|
if (s == null) return '—';
|
||||||
|
|||||||
Reference in New Issue
Block a user