diff --git a/README.md b/README.md index deb317a..9c60da8 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/docs/webui.md b/docs/webui.md index 8163675..f42ad40 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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 | diff --git a/webui/app.py b/webui/app.py index 27dabc2..528770d 100644 --- a/webui/app.py +++ b/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"'}) - 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("/") diff --git a/webui/static/style.css b/webui/static/style.css index 802225b..085430b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; +} diff --git a/webui/templates/index.html b/webui/templates/index.html index 45711bd..2263c6c 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -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) { diff --git a/webui/templates/login.html b/webui/templates/login.html new file mode 100644 index 0000000..21c50e1 --- /dev/null +++ b/webui/templates/login.html @@ -0,0 +1,28 @@ + + +
+ + +Вход в панель управления
+ {% if error %} +{{ error }}
+ {% endif %} + +