fix(webui): cookie session login/logout (replace Basic Auth)

Browsers cannot clear HTTP Basic credentials; logout never worked reliably.
Use Flask signed session + /login form + GET /logout + POST /api/logout.
Optional KASKAD_SECRET_KEY or auto /etc/kaskad/.session_secret.
Fetch API uses credentials: same-origin; 401 redirects to /login.

Docs + README updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-12 20:04:03 +03:00
parent b8757548a2
commit a2182d0b91
7 changed files with 182 additions and 57 deletions

View File

@@ -93,7 +93,7 @@ X-ray, ваши SSH-ключи и системные пакеты **не тро
## WebUI
После установки откройте `http://ВАШ_СЕРВЕР:8088`.
После установки откройте `http://ВАШ_СЕРВЕР:8088` — откроется страница входа. После входа сессия хранится в cookie; кнопка **Выход** надёжно завершает сессию.
| Что | Как |
|---|---|
@@ -153,7 +153,7 @@ kaskad/
- Все конфиги (`ru-servers.json`, `notify.env`, `webui.env`) с правами `600`, **не коммитятся**.
- Между серверами — только SSH-ключи. Пароли используются один раз, при добавлении нового сервера, и **сразу затираются**.
- WebUI защищён HTTP basic auth. Рекомендация: поставьте за HTTPS reverse-proxy (Caddy, Nginx, Traefik).
- WebUI: вход по логину/паролю на странице `/login`, сессия в cookie; за HTTPS reverse-proxy (Caddy, Nginx, Traefik).
- Telegram-бот принимает команды только от заранее заданного `TG_CHAT_ID`.
---

View File

@@ -15,7 +15,15 @@ Flask-приложение, бежит рядом с TG-ботом (на одн
## API
Все endpoint'ы под `/api/`, все требуют HTTP basic auth.
Все endpoint'ы под `/api/` требуют **куки-сессии** (страница входа `/login`), не HTTP Basic — иначе в браузере нельзя сделать надёжный «выход».
Из скрипта / curl — сначала POST на `/login` (как форма), потом запросы с сохранённой кукой:
```bash
curl -c jar.txt -b jar.txt -X POST 'http://127.0.0.1:8088/login' \
-d 'username=admin&password=ВАШ_ПАРОЛЬ&next=/'
curl -b jar.txt 'http://127.0.0.1:8088/api/state' | jq .
```
| Метод | Путь | Тело | Описание |
|---|---|---|---|
@@ -32,15 +40,9 @@ Flask-приложение, бежит рядом с TG-ботом (на одн
| DELETE | `/api/ips` | то же | Удалить |
| POST | `/api/ips/clear` | — | Очистить все доп. IP |
Пример:
```bash
curl -u admin:PASS https://your-host/api/state | jq
curl -u admin:PASS -X POST https://your-host/api/use -d '{"id":"primary"}' -H 'Content-Type: application/json'
```
## Безопасность
- Basic auth обязателен. `KASKAD_WEB_PASS` должен быть длинным и случайным (см. `webui.env.example`)
- Вход — форма `/login` + подписанная **cookie-сессия** (ключ `KASKAD_SECRET_KEY` или файл `/etc/kaskad/.session_secret`). Пароль админа — `KASKAD_WEB_PASS`, должен быть длинным и случайным.
- По умолчанию слушает на `0.0.0.0:8088`. **Рекомендуется** поставить за HTTPS reverse-proxy (nginx/caddy) с Let's Encrypt
- Если хочется привязать только к localhost — `KASKAD_HOST=127.0.0.1` и пользоваться через SSH-туннель: `ssh -L 8088:localhost:8088 root@ams1`
- Пароли SSH (`password=` при `/server-add`, `/ams-add`) передаются по HTTPS только если веб за reverse-proxy. Без HTTPS не передавай пароли через WebUI — используй ключи (см. `/bot-key`)
@@ -49,8 +51,9 @@ curl -u admin:PASS -X POST https://your-host/api/use -d '{"id":"primary"}' -H 'C
| Переменная | Дефолт | Описание |
|---|---|---|
| `KASKAD_WEB_USER` | `admin` | Логин для basic auth |
| `KASKAD_WEB_USER` | `admin` | Логин для входа в WebUI |
| `KASKAD_WEB_PASS` | (нет) | Пароль; ОБЯЗАТЕЛЬНО задать |
| `KASKAD_SECRET_KEY` | (файл) | Секрет подписи сессии; иначе создаётся `/etc/kaskad/.session_secret` |
| `LOCAL_HOST` | `ams1` | Имя локального ам. сервера |
| `LOCAL_IP` | `127.0.0.1` | Локальный IP — определяет, какой ам. читать локально без SSH |
| `KASKAD_HOST` | `0.0.0.0` | Адрес для bind |

View File

@@ -5,13 +5,17 @@ Kaskad Web UI - dashboard для управления RU/ам. серверам
Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит
те же скрипты (ru-set.sh, ru-routes.sh, ru-domains.py).
"""
import base64, functools, json, os, re, shlex, socket, subprocess
import base64, functools, json, os, re, secrets, shlex, subprocess
from datetime import timedelta
from pathlib import Path
from flask import Flask, jsonify, render_template, request, Response
from typing import Optional
from flask import Flask, jsonify, render_template, request, Response, session, redirect, url_for
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"))
SESSION_SECRET_FILE = Path(os.environ.get("KASKAD_SESSION_SECRET_FILE", "/etc/kaskad/.session_secret"))
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")
@@ -22,16 +26,61 @@ app = Flask(__name__, template_folder="templates", static_folder="static")
CIDR_RX = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\b")
# --- auth ---
def _session_secret_key() -> str:
sk = os.environ.get("KASKAD_SECRET_KEY", "").strip()
if sk:
return sk
try:
if SESSION_SECRET_FILE.exists():
return SESSION_SECRET_FILE.read_text().strip()
except OSError:
pass
key = secrets.token_hex(32)
try:
SESSION_SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
if not SESSION_SECRET_FILE.exists():
SESSION_SECRET_FILE.write_text(key)
SESSION_SECRET_FILE.chmod(0o600)
else:
key = SESSION_SECRET_FILE.read_text().strip()
except OSError:
pass
return key
app.secret_key = _session_secret_key()
app.config.update(
SESSION_COOKIE_NAME="kaskad",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
)
def _safe_next(url: Optional[str]) -> str:
if not url:
return "/"
url = url.split("#", 1)[0]
if not url.startswith("/") or url.startswith("//"):
return "/"
return url
def _session_ok() -> bool:
return bool(session.get("kaskad"))
# --- auth (cookie session; Basic Auth в браузере нельзя надёжно сбросить) ---
def require_auth(fn):
@functools.wraps(fn)
def w(*a, **kw):
if not WEB_PASS:
return Response("KASKAD_WEB_PASS не задан", 500)
auth = request.authorization
if not auth or auth.username != WEB_USER or auth.password != WEB_PASS:
return Response("Auth required", 401, {"WWW-Authenticate": 'Basic realm="kaskad"'})
return fn(*a, **kw)
if _session_ok():
return fn(*a, **kw)
if request.path.startswith("/api/"):
return jsonify(error="требуется вход"), 401
return redirect(url_for("login", next=request.path))
return w
@@ -592,21 +641,35 @@ def api_change_password():
return jsonify(ok=True)
@app.route("/api/logout")
def api_logout():
"""401 + смена realm — подсказка браузеру забыть предыдущий Basic Auth.
@app.route("/login", methods=["GET", "POST"])
def login():
"""Форма входа; сессия Flask (cookie), не HTTP Basic."""
if _session_ok():
return redirect(_safe_next(request.args.get("next")))
next_url = _safe_next(request.args.get("next", "/"))
if request.method == "POST":
next_url = _safe_next(request.form.get("next"))
u = request.form.get("username", "").strip()
p = request.form.get("password", "")
if u == WEB_USER and p == WEB_PASS:
session.clear()
session["kaskad"] = True
session.permanent = True
return redirect(next_url)
return render_template("login.html", error="Неверный логин или пароль", next_url=next_url)
return render_template("login.html", next_url=next_url)
Клиент должен дергать этот URL через XMLHttpRequest.open(..., user, password)
с заведомо неверной парой; иначе многие браузеры подставят сохранённые креды."""
return Response(
"logged out\n",
401,
{
"WWW-Authenticate": f'Basic realm="kaskad-logout-{os.urandom(4).hex()}"',
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@app.route("/logout")
def logout_page():
session.clear()
return redirect(url_for("login"))
@app.route("/api/logout", methods=["POST"])
def api_logout():
session.clear()
return jsonify(ok=True)
@app.route("/")

View File

@@ -157,3 +157,43 @@ footer .foot-row {
}
footer .foot-row.muted { color: #6e7681; font-size: 11px; margin-top: 8px; }
footer a { margin: 0 2px; }
/* --- login --- */
body.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.login-box {
width: 100%;
max-width: 380px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 28px;
}
.login-box h1 { margin: 0 0 8px 0; }
.login-box form {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.login-box form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #8b949e;
}
.login-box form input {
width: 100%;
box-sizing: border-box;
}
.login-box form button {
width: 100%;
margin-top: 8px;
padding: 10px 16px;
}

View File

@@ -246,9 +246,15 @@
this.loading = true; this.error = '';
try {
const r = await fetch(url, {
method, headers: {'Content-Type':'application/json'},
method,
credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401) {
window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname || '/');
throw new Error('требуется вход');
}
const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
@@ -278,33 +284,15 @@
});
} catch { return; }
this.settingsOpen = false;
this.flash('Пароль изменён. Сейчас браузер попросит новый.');
setTimeout(() => this.logout(), 1200);
this.flash('Пароль изменён');
this.forms.pwd = {current:'', new:'', confirm:''};
},
logout() {
// HTTP Basic Auth живёт в браузере отдельно от cookies. fetch() с ручным
// Authorization часто ИГНОРИРУЕТСЯ — Chrome подставляет сохранённый пароль.
// XMLHttpRequest.open(..., user, password) заставляет уйти именно эта пара
// (заведомо ложная), сервер отвечает 401 + новый realm — после этого
// location.replace('/') обычно снова показывает окно входа.
const go = () => {
const u = new URL(window.location.href);
u.pathname = '/';
u.search = '_bye=' + Date.now();
u.hash = '';
window.location.replace(u.href);
};
const noise = () => Math.random().toString(36).slice(2) + Date.now();
async logout() {
try {
const xhr = new XMLHttpRequest();
xhr.timeout = 12000;
xhr.open('GET', '/api/logout?_=' + noise(), true, '__logout', noise());
xhr.onload = xhr.onerror = xhr.ontimeout = go;
xhr.send();
} catch (e) {
go();
}
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
} catch {}
window.location.href = '/login';
},
fmtAge(s) {

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Kaskad</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="login-page">
<div class="login-box">
<h1>Каскад</h1>
<p class="muted">Вход в панель управления</p>
{% if error %}
<p class="status err" style="margin:12px 0">{{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('login') }}">
<input type="hidden" name="next" value="{{ next_url }}">
<label>Логин
<input name="username" autocomplete="username" required autofocus>
</label>
<label>Пароль
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="danger solid">Войти</button>
</form>
</div>
</body>
</html>

View File

@@ -13,5 +13,8 @@ KASKAD_PORT=8088
# SSH-ключ для доступа к остальным серверам
KASKAD_SSH_KEY=/root/.ssh/id_ed25519
# Опционально: ключ подписи сессии (иначе создаётся /etc/kaskad/.session_secret)
# KASKAD_SECRET_KEY=
# Путь к JSON конфигу
KASKAD_SERVERS_JSON=/etc/wireguard/ru-servers.json