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

View File

@@ -15,7 +15,15 @@ Flask-приложение, бежит рядом с TG-ботом (на одн
## API ## 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` | то же | Удалить | | DELETE | `/api/ips` | то же | Удалить |
| POST | `/api/ips/clear` | — | Очистить все доп. IP | | 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 - По умолчанию слушает на `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` - Если хочется привязать только к 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`) - Пароли 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_WEB_PASS` | (нет) | Пароль; ОБЯЗАТЕЛЬНО задать |
| `KASKAD_SECRET_KEY` | (файл) | Секрет подписи сессии; иначе создаётся `/etc/kaskad/.session_secret` |
| `LOCAL_HOST` | `ams1` | Имя локального ам. сервера | | `LOCAL_HOST` | `ams1` | Имя локального ам. сервера |
| `LOCAL_IP` | `127.0.0.1` | Локальный IP — определяет, какой ам. читать локально без SSH | | `LOCAL_IP` | `127.0.0.1` | Локальный IP — определяет, какой ам. читать локально без SSH |
| `KASKAD_HOST` | `0.0.0.0` | Адрес для bind | | `KASKAD_HOST` | `0.0.0.0` | Адрес для bind |

View File

@@ -5,13 +5,17 @@ Kaskad Web UI - dashboard для управления RU/ам. серверам
Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит
те же скрипты (ru-set.sh, ru-routes.sh, ru-domains.py). те же скрипты (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 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")) 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")) 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_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")
@@ -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") 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): def require_auth(fn):
@functools.wraps(fn) @functools.wraps(fn)
def w(*a, **kw): def w(*a, **kw):
if not WEB_PASS: if not WEB_PASS:
return Response("KASKAD_WEB_PASS не задан", 500) return Response("KASKAD_WEB_PASS не задан", 500)
auth = request.authorization if _session_ok():
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) return fn(*a, **kw)
if request.path.startswith("/api/"):
return jsonify(error="требуется вход"), 401
return redirect(url_for("login", next=request.path))
return w return w
@@ -592,21 +641,35 @@ def api_change_password():
return jsonify(ok=True) return jsonify(ok=True)
@app.route("/api/logout") @app.route("/login", methods=["GET", "POST"])
def api_logout(): def login():
"""401 + смена realm — подсказка браузеру забыть предыдущий Basic Auth. """Форма входа; сессия 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)
с заведомо неверной парой; иначе многие браузеры подставят сохранённые креды.""" @app.route("/logout")
return Response( def logout_page():
"logged out\n", session.clear()
401, return redirect(url_for("login"))
{
"WWW-Authenticate": f'Basic realm="kaskad-logout-{os.urandom(4).hex()}"',
"Cache-Control": "no-store, no-cache, must-revalidate", @app.route("/api/logout", methods=["POST"])
"Pragma": "no-cache", def api_logout():
}, session.clear()
) return jsonify(ok=True)
@app.route("/") @app.route("/")

View File

@@ -157,3 +157,43 @@ footer .foot-row {
} }
footer .foot-row.muted { color: #6e7681; font-size: 11px; margin-top: 8px; } footer .foot-row.muted { color: #6e7681; font-size: 11px; margin-top: 8px; }
footer a { margin: 0 2px; } 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 = ''; this.loading = true; this.error = '';
try { try {
const r = await fetch(url, { 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 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(()=>({})); const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.error || r.statusText); if (!r.ok) throw new Error(j.error || r.statusText);
return j; return j;
@@ -278,33 +284,15 @@
}); });
} catch { return; } } catch { return; }
this.settingsOpen = false; this.settingsOpen = false;
this.flash('Пароль изменён. Сейчас браузер попросит новый.'); this.flash('Пароль изменён');
setTimeout(() => this.logout(), 1200); this.forms.pwd = {current:'', new:'', confirm:''};
}, },
logout() { async 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();
try { try {
const xhr = new XMLHttpRequest(); await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
xhr.timeout = 12000; } catch {}
xhr.open('GET', '/api/logout?_=' + noise(), true, '__logout', noise()); window.location.href = '/login';
xhr.onload = xhr.onerror = xhr.ontimeout = go;
xhr.send();
} catch (e) {
go();
}
}, },
fmtAge(s) { 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-ключ для доступа к остальным серверам # SSH-ключ для доступа к остальным серверам
KASKAD_SSH_KEY=/root/.ssh/id_ed25519 KASKAD_SSH_KEY=/root/.ssh/id_ed25519
# Опционально: ключ подписи сессии (иначе создаётся /etc/kaskad/.session_secret)
# KASKAD_SECRET_KEY=
# Путь к JSON конфигу # Путь к JSON конфигу
KASKAD_SERVERS_JSON=/etc/wireguard/ru-servers.json KASKAD_SERVERS_JSON=/etc/wireguard/ru-servers.json