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
|
## 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
105
webui/app.py
105
webui/app.py
@@ -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 fn(*a, **kw)
|
||||||
return Response("Auth required", 401, {"WWW-Authenticate": 'Basic realm="kaskad"'})
|
if request.path.startswith("/api/"):
|
||||||
return fn(*a, **kw)
|
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("/")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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-ключ для доступа к остальным серверам
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user