mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-20 13:49:56 +00:00
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:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
|
||||
103
webui/app.py
103
webui/app.py
@@ -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"'})
|
||||
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("/")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
28
webui/templates/login.html
Normal file
28
webui/templates/login.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user