mirror of
https://github.com/andrey271192/kaskad_web_vpn.git
synced 2026-09-21 12:01:59 +00:00
feat: first-run /setup password + PRO-style iptables
compat DNAT/INPUT/FORWARD/MASQUERADE; optional chain mode; auth_store hash; static absolute URLs; install skips env password by default; README. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,7 +33,7 @@ RUN set -eux; \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app.py kaskad_store.py system_info.py /app/
|
COPY app.py auth_store.py kaskad_store.py system_info.py /app/
|
||||||
COPY templates ./templates
|
COPY templates ./templates
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
|
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -1,86 +1,89 @@
|
|||||||
# kaskad_web_vpn — Kaskad Web UI v2
|
# kaskad_web_vpn — Kaskad Web UI v2
|
||||||
|
|
||||||
Веб-панель на **Flask** с **HTTP Basic Auth**: дашборд как у классического «каскада» — блок **Система**, таблица **Сервисы**, CRUD **клиентов (NAT / DNAT)** и просмотр цепочки **iptables**. В шапке — ваши ссылки на поддержку (GitHub, Boosty, Ozon СБП, Telegram), без чужой рекламы в подвале.
|
Веб-панель на **Flask** с **HTTP Basic Auth**: дашборд в духе «каскада» — **Система**, **Сервисы**, CRUD **NAT / DNAT**, сырые **iptables**. Это **отдельная открытая реализация** под ваш GitHub: без пунктов меню PROMO/«anten-ka», без телеграм-бота и терминального `gokaskad` из коммерческого Kaskad PRO; зато установка **curl | bash** с этого репозитория и понятный NAT для AmneziaWG / TCP по схеме «RU VPS → DNAT → зарубежный сервер».
|
||||||
|
|
||||||
Репозиторий: [github.com/andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn)
|
Репозиторий: [github.com/andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn)
|
||||||
|
|
||||||
## Важно про NAT
|
## Первый вход и пароль
|
||||||
|
|
||||||
Правила записываются в **`nat`**, отдельная цепочка **`KASKAD_WEB`**, подключение к **`PREROUTING`** (DNAT `входящий_порт → target:порт`). Для рабочего каскада на VPS нужны **`net.ipv4.ip_forward=1`** и разрешения в **`filter/FORWARD`** (настраиваются на хосте отдельно).
|
- **По умолчанию** скрипт установки **не** задаёт пароль в Docker: откройте в браузере **`http://<ваш_IP>:<порт>/setup`**, придумайте пароль (≥ 8 символов) и логин (часто оставляют `user1`). Хэш сохраняется в томе: **`/var/lib/kaskad/web_auth.json`**.
|
||||||
|
- Чтобы задать пароль сразу при установке: **`ADMIN_PASSWORD`** или файл **`PASSWORD_FILE`** — тогда шаг `/setup` не нужен.
|
||||||
|
- Как раньше (случайный пароль в файл): **`FORCE_RANDOM_PASSWORD=1`**.
|
||||||
|
- Если заданы **`BASIC_AUTH_PASSWORD`** / **`ADMIN_PASSWORD`**, используется они (перекрывают файл).
|
||||||
|
|
||||||
По умолчанию установка поднимает контейнер с **`--network host`** и **`--privileged`**, чтобы **iptables менял таблицы самого хоста**. Режим только с `-p порт:8088` без host network применим для просмотра UI, но DNAT с контейнера на хост в таком виде обычно **не** используют.
|
## NAT и совместимость с логикой Kaskad PRO
|
||||||
|
|
||||||
Данные правил: **`KASKAD_DATA_DIR`** на хосте → `/var/lib/kaskad/rules.json` в контейнере.
|
Переменная **`KASKAD_IPTABLES_MODE`** (в контейнере, install передаёт **`compat`** по умолчанию):
|
||||||
|
|
||||||
|
| Значение | Поведение |
|
||||||
|
|----------|-----------|
|
||||||
|
| **`compat`** (по умолчанию) | Как в Kaskad PRO: **DNAT** в **`nat/PREROUTING`**, **INPUT** и парой правил **FORWARD** с комментарием **`kaskad:PORT:proto`**, один раз **MASQUERADE** на исходящем интерфейсе (авто через `ip route` или **`KASKAD_OUT_IFACE`**). |
|
||||||
|
| **`chain`** | Старая схема: отдельная цепочка **`nat/KASKAD_WEB`** и jump из PREROUTING. |
|
||||||
|
|
||||||
|
Нужны **`net.ipv4.ip_forward=1`** и достаточное место на диске под Docker.
|
||||||
|
|
||||||
|
Установка по умолчанию: **`--network host`** и **`--privileged`**, чтобы правила применялись к **таблицам хоста**. Данные правил: **`KASKAD_DATA_DIR`** → `/var/lib/kaskad/rules.json`.
|
||||||
|
|
||||||
|
Попытка сохранить правила в **`netfilter-persistent`**, если установлен на хосте.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Linux с Docker (для NAT — предпочтительно host network + privileged).
|
- Linux с Docker (для NAT — host network + privileged).
|
||||||
- Доступ к **`docker.sock`** на хосте (по умолчанию монтируется), чтобы в таблице «Сервисы» третья строка могла показывать статус **самого контейнера** как `kaskad-web.service`.
|
- По желанию **`/var/run/docker.sock`** (чтобы в «Сервисах» отображался статус контейнера как `kaskad-web.service`).
|
||||||
|
|
||||||
## Сборка образа: «invalid signature» / apt не подписан
|
## Сборка образа: «invalid signature» / apt
|
||||||
|
|
||||||
Чаще всего: **битый кэш Docker BuildKit**, **прокси по HTTP**, **место на диске**, **сбитые часы** на хосте.
|
См. блок в Wiki ниже — типично кэш builder, прокси, диск, время.
|
||||||
|
|
||||||
На хосте:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
date -u # время должно быть разумным
|
docker builder prune -af
|
||||||
df -h /var/lib/docker # есть ли свободное место
|
|
||||||
docker builder prune -af # сбросить кэш сборки
|
|
||||||
docker build --pull --no-cache -t kaskad-web-vpn:test .
|
docker build --pull --no-cache -t kaskad-web-vpn:test .
|
||||||
```
|
```
|
||||||
|
|
||||||
Если ошибка остаётся: временно отключите HTTP-прокси для сборки (`unset http_proxy https_proxy NO_PROXY`) или собирайте на другой сети.
|
В Dockerfile зеркала APT переведены на HTTPS.
|
||||||
|
|
||||||
В образе списки APT по умолчанию переводятся на **HTTPS** (`deb.debian.org`, `security.debian.org`), чтобы обходить «порченые» HTTP-кэши.
|
|
||||||
|
|
||||||
## Установка одной командой
|
## Установка одной командой
|
||||||
|
|
||||||
Порт **8088**, логин **user1**, пароль в **`/root/kaskad_web.initial-password`** (или задайте **`ADMIN_PASSWORD`**):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Свой пароль и порт:
|
Затем откройте **`/setup`** или задайте пароль при установке:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \
|
||||||
env ADMIN_PASSWORD='ваш_секрет' HOST_PORT=8443 bash
|
env ADMIN_PASSWORD='ваш_секрет' HOST_PORT=8443 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Панель Amnezia (опционально, ссылка на странице):
|
Старый режим «случайный пароль в файл»:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \
|
||||||
env PANEL_URL='https://panel.example.com' bash
|
env FORCE_RANDOM_PASSWORD=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
### Переменные установки (install.sh)
|
### Переменные install.sh
|
||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
| Переменная | По умолчанию | Описание |
|
||||||
|------------|--------------|----------|
|
|------------|--------------|----------|
|
||||||
| `HOST_PORT` | `8088` | Порт HTTP (при host network = `PORT` внутри процесса) |
|
| `HOST_PORT` | `8088` | HTTP-порт |
|
||||||
| `KASKAD_HOST_NETWORK` | `1` | `1` — `--network host`; `0` — проброс `-p HOST_PORT:8088` (NAT на хост может быть недоступен) |
|
| `KASKAD_HOST_NETWORK` | `1` | host network / или `-p` |
|
||||||
| `KASKAD_DATA_DIR` | `/var/lib/kaskad` | Том с `rules.json` |
|
| `KASKAD_DATA_DIR` | `/var/lib/kaskad` | том данных |
|
||||||
| `MOUNT_DOCKER_SOCK` | `1` | Монтировать `/var/run/docker.sock` для статуса контейнера в «Сервисы» |
|
| `KASKAD_IPTABLES_MODE` | `compat` | `compat` или `chain` |
|
||||||
| `BASIC_AUTH_USER` | `user1` | Логин Basic Auth |
|
| `ADMIN_PASSWORD` | — | пароль без `/setup` |
|
||||||
| `ADMIN_PASSWORD` | — | Пароль |
|
| `FORCE_RANDOM_PASSWORD` | `0` | `1` — автопароль в `PASSWORD_FILE` |
|
||||||
| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | Файл пароля при автогенерации |
|
| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | при FORCE_RANDOM или чтении пароля |
|
||||||
| `PANEL_URL` | пусто | Доп. ссылка в шапке |
|
| `BASIC_AUTH_USER` | `user1` | логин при использовании env-пароля |
|
||||||
| `KASKAD_REPO` | `andrey271192/kaskad_web_vpn` | Репозиторий |
|
| `PANEL_URL` | — | ссылка в шапке |
|
||||||
| `KASKAD_REF` | `main` | Ветка |
|
|
||||||
|
|
||||||
### Переменные внутри приложения (Docker `-e`)
|
### Переменные приложения
|
||||||
|
|
||||||
| Переменная | Описание |
|
| Переменная | Описание |
|
||||||
|------------|----------|
|
|------------|----------|
|
||||||
| `BOT_TOKEN`, `BOT_CHAT_ID` | Отображаются в блоке «Система» в замаскированном виде |
|
| `BOT_TOKEN`, `BOT_CHAT_ID` | только отображение в «Системе» (маска), без бота |
|
||||||
| `SERVICE_UNITS` | CSV юнитов systemd для первых двух строк (по умолчанию `kaskad_bot.service,kaskad-monitor.service,kaskad-web.service`) |
|
| `SERVICE_UNITS`, `DOCKER_WEB_*` | таблица сервисов |
|
||||||
| `DOCKER_WEB_CONTAINER` | Имя контейнера для третьей строки (install выставляет автоматически) |
|
| `KASKAD_OUT_IFACE` | исходящий интерфейс для MASQUERADE |
|
||||||
| `DOCKER_WEB_DISPLAY_UNIT` | Подпись в таблице для этой строки (по умолчанию `kaskad-web.service`) |
|
| `WEB_AUTH_JSON` | путь к `web_auth.json` |
|
||||||
| `KASKAD_UI_VERSION` | Версия в блоке «yaskad» (по умолчанию `v2.2`) |
|
|
||||||
| `KASKAD_NAT_CHAIN` | Имя цепочки в `nat` (по умолчанию `KASKAD_WEB`) |
|
|
||||||
|
|
||||||
## Удаление контейнера
|
## Удаление контейнера
|
||||||
|
|
||||||
@@ -88,38 +91,23 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/in
|
|||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Образ:
|
Режим **`chain`**: удаление jump в **`KASKAD_WEB`** см. исторический README в git.
|
||||||
|
|
||||||
```bash
|
Режим **`compat`**: проще снять правила через панель (удалить клиентов) или выборочно удалять строки с **`kaskad:`** в `iptables -S INPUT/FORWARD` и соответствующие DNAT в `iptables -t nat -S PREROUTING`.
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo env REMOVE_IMAGE=1 bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Цепочку iptables после экспериментов можно убрать вручную (осторожно, на проде проверяйте порядок правил):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
iptables -t nat -D PREROUTING -j KASKAD_WEB # повторять, пока не вернёт ошибку
|
|
||||||
iptables -t nat -F KASKAD_WEB
|
|
||||||
iptables -t nat -X KASKAD_WEB
|
|
||||||
```
|
|
||||||
|
|
||||||
## Локально без Docker
|
## Локально без Docker
|
||||||
|
|
||||||
Нужны root-права для `iptables` и установленные `iptables` / `ip`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
export BASIC_AUTH_PASSWORD='secret'
|
# либо задайте BASIC_AUTH_PASSWORD, либо откройте /setup
|
||||||
sudo -E env PATH="$PATH" flask --app app run --host 0.0.0.0 --port 8088
|
sudo -E env PATH="$PATH" flask --app app run --host 0.0.0.0 --port 8088
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверка без авторизации: `GET /health`.
|
`GET /health` без авторизации. `GET /api/meta` — флаг `needs_setup`, режим iptables.
|
||||||
|
|
||||||
## API (после Basic Auth)
|
## API
|
||||||
|
|
||||||
- `GET /api/system`, `/api/services`, `/api/clients`
|
После Basic Auth: `/api/system`, `/api/services`, `/api/clients`, CRUD клиентов, `/api/iptables/raw`.
|
||||||
- `POST /api/clients`, `PUT /api/clients/<id>`, `DELETE /api/clients/<id>`
|
|
||||||
- `GET /api/iptables/raw`, `POST /api/iptables/sync`
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
88
app.py
88
app.py
@@ -1,13 +1,17 @@
|
|||||||
"""Kaskad Web UI v2 — Flask + Basic Auth + NAT CRUD."""
|
"""Kaskad Web UI v2 — Flask, первичная настройка пароля, NAT CRUD."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request
|
from flask import Flask, Response, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
import auth_store
|
||||||
from kaskad_store import (
|
from kaskad_store import (
|
||||||
|
IPTABLES_MODE,
|
||||||
iptables_chain_dump,
|
iptables_chain_dump,
|
||||||
load_rules,
|
load_rules,
|
||||||
normalize_rule,
|
normalize_rule,
|
||||||
@@ -20,18 +24,83 @@ from system_info import collect_services_rows, collect_system_payload
|
|||||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
USER = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1"
|
|
||||||
PW = os.environ.get("BASIC_AUTH_PASSWORD", "").strip()
|
|
||||||
REALM = os.environ.get("BASIC_AUTH_REALM", "kaskad").strip() or "kaskad"
|
REALM = os.environ.get("BASIC_AUTH_REALM", "kaskad").strip() or "kaskad"
|
||||||
PANEL_URL = os.environ.get("PANEL_URL", "").strip()
|
PANEL_URL = os.environ.get("PANEL_URL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _flask_secret_key() -> str:
|
||||||
|
p = Path(os.environ.get("FLASK_SECRET_PATH", "/var/lib/kaskad/.flask_secret"))
|
||||||
|
env = os.environ.get("FLASK_SECRET_KEY", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
try:
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if p.is_file():
|
||||||
|
return p.read_text(encoding="utf-8").strip()
|
||||||
|
key = secrets.token_hex(32)
|
||||||
|
p.write_text(key + "\n", encoding="utf-8")
|
||||||
|
return key
|
||||||
|
except OSError:
|
||||||
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = _flask_secret_key()
|
||||||
|
|
||||||
|
|
||||||
def check_auth(u: str, p: str) -> bool:
|
def check_auth(u: str, p: str) -> bool:
|
||||||
if not PW:
|
if not auth_store.password_is_configured():
|
||||||
return False
|
return False
|
||||||
return u == USER and p == PW
|
return auth_store.verify_credentials(u or "", p or "")
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def require_initial_setup():
|
||||||
|
if request.endpoint == "static":
|
||||||
|
return None
|
||||||
|
ep = request.endpoint or ""
|
||||||
|
if ep in ("health", "setup", "api_meta"):
|
||||||
|
return None
|
||||||
|
if auth_store.password_is_configured():
|
||||||
|
return None
|
||||||
|
if request.path.startswith("/api"):
|
||||||
|
return jsonify({"error": "needs_setup", "needs_setup": True}), 503
|
||||||
|
if ep != "setup":
|
||||||
|
return redirect(url_for("setup"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/meta")
|
||||||
|
def api_meta():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"needs_setup": not auth_store.password_is_configured(),
|
||||||
|
"iptables_mode": IPTABLES_MODE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/setup", methods=("GET", "POST"))
|
||||||
|
def setup():
|
||||||
|
if auth_store.password_is_configured():
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
err = None
|
||||||
|
if request.method == "POST":
|
||||||
|
pw = (request.form.get("password") or "").strip()
|
||||||
|
pw2 = (request.form.get("password2") or "").strip()
|
||||||
|
user = (request.form.get("username") or auth_store.DEFAULT_USER).strip() or auth_store.DEFAULT_USER
|
||||||
|
if len(pw) < 8:
|
||||||
|
err = "Пароль не короче 8 символов."
|
||||||
|
elif pw != pw2:
|
||||||
|
err = "Пароли не совпадают."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
auth_store.save_password(user, pw)
|
||||||
|
log.info("web auth создан для пользователя %s", user)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except OSError as e:
|
||||||
|
err = f"Не удалось сохранить: {e}"
|
||||||
|
return render_template("setup.html", error=err, default_user=auth_store.DEFAULT_USER)
|
||||||
|
|
||||||
|
|
||||||
def requires_auth(view):
|
def requires_auth(view):
|
||||||
@@ -59,7 +128,12 @@ def health():
|
|||||||
@requires_auth
|
@requires_auth
|
||||||
def index():
|
def index():
|
||||||
title = os.environ.get("KASKAD_UI_TITLE", "Kaskad Web UI v2").strip()
|
title = os.environ.get("KASKAD_UI_TITLE", "Kaskad Web UI v2").strip()
|
||||||
return render_template("index.html", panel_url=PANEL_URL, ui_title=title)
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
panel_url=PANEL_URL,
|
||||||
|
ui_title=title,
|
||||||
|
iptables_mode=IPTABLES_MODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _need_auth_json(view):
|
def _need_auth_json(view):
|
||||||
|
|||||||
60
auth_store.py
Normal file
60
auth_store.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Пароль веб-интерфейса: переменная окружения или файл после первого входа."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
AUTH_JSON = Path(os.environ.get("WEB_AUTH_JSON", "/var/lib/kaskad/web_auth.json"))
|
||||||
|
DEFAULT_USER = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_auth_dir() -> None:
|
||||||
|
AUTH_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_from_env() -> bool:
|
||||||
|
return bool(os.environ.get("BASIC_AUTH_PASSWORD", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def load_record() -> dict | None:
|
||||||
|
if not AUTH_JSON.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(AUTH_JSON.read_text(encoding="utf-8"))
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def password_is_configured() -> bool:
|
||||||
|
if auth_from_env():
|
||||||
|
return True
|
||||||
|
rec = load_record()
|
||||||
|
return bool(rec and rec.get("password_hash"))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_credentials(username: str, password: str) -> bool:
|
||||||
|
if auth_from_env():
|
||||||
|
u = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1"
|
||||||
|
epw = os.environ.get("BASIC_AUTH_PASSWORD", "").strip()
|
||||||
|
return username == u and password == epw
|
||||||
|
|
||||||
|
rec = load_record()
|
||||||
|
if not rec or not rec.get("password_hash"):
|
||||||
|
return False
|
||||||
|
u = str(rec.get("user") or DEFAULT_USER)
|
||||||
|
return username == u and check_password_hash(str(rec["password_hash"]), password)
|
||||||
|
|
||||||
|
|
||||||
|
def save_password(username: str, plaintext: str) -> None:
|
||||||
|
ensure_auth_dir()
|
||||||
|
h = generate_password_hash(plaintext)
|
||||||
|
tmp = AUTH_JSON.with_suffix(".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps({"user": username.strip() or DEFAULT_USER, "password_hash": h}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tmp.replace(AUTH_JSON)
|
||||||
40
install.sh
40
install.sh
@@ -54,29 +54,31 @@ mv "$EXTRACTED"/* "$SRC"/
|
|||||||
echo "Сборка образа ${IMAGE} …"
|
echo "Сборка образа ${IMAGE} …"
|
||||||
docker build -t "$IMAGE" "$SRC"
|
docker build -t "$IMAGE" "$SRC"
|
||||||
|
|
||||||
|
FORCE_RANDOM_PASSWORD="${FORCE_RANDOM_PASSWORD:-0}"
|
||||||
|
|
||||||
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||||
echo "Удаление старого контейнера ${CONTAINER} …"
|
echo "Удаление старого контейнера ${CONTAINER} …"
|
||||||
docker rm -f "$CONTAINER" >/dev/null
|
docker rm -f "$CONTAINER" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
AUTH_ENV_ARGS=()
|
||||||
|
|
||||||
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
|
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
|
||||||
BASIC_AUTH_PASSWORD="$ADMIN_PASSWORD"
|
AUTH_ENV_ARGS+=( -e "BASIC_AUTH_PASSWORD=${ADMIN_PASSWORD}" )
|
||||||
elif [[ -f "$PASSWORD_FILE" ]] && grep -qs . "$PASSWORD_FILE"; then
|
elif [[ -f "$PASSWORD_FILE" ]] && grep -qs . "$PASSWORD_FILE"; then
|
||||||
BASIC_AUTH_PASSWORD="$(tr -d '\r\n' <"$PASSWORD_FILE")"
|
AUTH_ENV_ARGS+=( -e "BASIC_AUTH_PASSWORD=$(tr -d '\r\n' <"$PASSWORD_FILE")" )
|
||||||
else
|
elif [[ "$FORCE_RANDOM_PASSWORD" == "1" ]]; then
|
||||||
if command -v openssl >/dev/null 2>&1; then
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
BASIC_AUTH_PASSWORD="$(openssl rand -hex 16)"
|
GEN_PW="$(openssl rand -hex 16)"
|
||||||
else
|
else
|
||||||
BASIC_AUTH_PASSWORD="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)"
|
GEN_PW="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)"
|
||||||
fi
|
fi
|
||||||
umask 077
|
umask 077
|
||||||
printf '%s\n' "$BASIC_AUTH_PASSWORD" >"$PASSWORD_FILE"
|
printf '%s\n' "$GEN_PW" >"$PASSWORD_FILE"
|
||||||
echo "Сгенерирован пароль Basic Auth, сохранён в ${PASSWORD_FILE}"
|
AUTH_ENV_ARGS+=( -e "BASIC_AUTH_PASSWORD=${GEN_PW}" )
|
||||||
fi
|
echo "Сгенерирован пароль (FORCE_RANDOM_PASSWORD=1), файл ${PASSWORD_FILE}"
|
||||||
|
else
|
||||||
if [[ -z "$BASIC_AUTH_PASSWORD" ]]; then
|
echo "Пароль веб-панели: откройте http://<хост>:${HOST_PORT}/setup при первом запуске (том ${KASKAD_DATA_DIR})."
|
||||||
echo "BASIC_AUTH_PASSWORD пуст — задайте ADMIN_PASSWORD или файл ${PASSWORD_FILE}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN_ARGS=(
|
RUN_ARGS=(
|
||||||
@@ -89,10 +91,12 @@ RUN_ARGS=(
|
|||||||
-e "DOCKER_WEB_CONTAINER=${CONTAINER}"
|
-e "DOCKER_WEB_CONTAINER=${CONTAINER}"
|
||||||
-e "DOCKER_WEB_DISPLAY_UNIT=kaskad-web.service"
|
-e "DOCKER_WEB_DISPLAY_UNIT=kaskad-web.service"
|
||||||
-e "BASIC_AUTH_USER=${BASIC_AUTH_USER}"
|
-e "BASIC_AUTH_USER=${BASIC_AUTH_USER}"
|
||||||
-e "BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD}"
|
|
||||||
-e "BASIC_AUTH_REALM=${BASIC_AUTH_REALM}"
|
-e "BASIC_AUTH_REALM=${BASIC_AUTH_REALM}"
|
||||||
|
-e "KASKAD_IPTABLES_MODE=${KASKAD_IPTABLES_MODE:-compat}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RUN_ARGS+=("${AUTH_ENV_ARGS[@]}")
|
||||||
|
|
||||||
if [[ "${MOUNT_DOCKER_SOCK:-1}" == "1" ]] && [[ -S /var/run/docker.sock ]]; then
|
if [[ "${MOUNT_DOCKER_SOCK:-1}" == "1" ]] && [[ -S /var/run/docker.sock ]]; then
|
||||||
RUN_ARGS+=( -v /var/run/docker.sock:/var/run/docker.sock )
|
RUN_ARGS+=( -v /var/run/docker.sock:/var/run/docker.sock )
|
||||||
fi
|
fi
|
||||||
@@ -114,6 +118,10 @@ docker run "${RUN_ARGS[@]}" "$IMAGE"
|
|||||||
echo "Готово."
|
echo "Готово."
|
||||||
echo " URL: http://$(hostname -f 2>/dev/null || hostname):${HOST_PORT}/"
|
echo " URL: http://$(hostname -f 2>/dev/null || hostname):${HOST_PORT}/"
|
||||||
echo " Логин: ${BASIC_AUTH_USER}"
|
echo " Логин: ${BASIC_AUTH_USER}"
|
||||||
echo " Пароль: см. ${PASSWORD_FILE} (или переменная ADMIN_PASSWORD при установке)"
|
if [[ "${#AUTH_ENV_ARGS[@]}" -gt 0 ]]; then
|
||||||
echo " Правила NAT: цепочка nat/KASKAD_WEB, данные: ${KASKAD_DATA_DIR}"
|
echo " Пароль: из ADMIN_PASSWORD / ${PASSWORD_FILE} / автогенерация (см. выше)."
|
||||||
echo " Рекомендуется: sysctl net.ipv4.ip_forward=1 и корректный FORWARD в filter."
|
else
|
||||||
|
echo " Пароль: создайте на странице /setup при первом открытии."
|
||||||
|
fi
|
||||||
|
echo " NAT: режим ${KASKAD_IPTABLES_MODE:-compat} (compat ≈ Kaskad PRO), данные: ${KASKAD_DATA_DIR}"
|
||||||
|
echo " Рекомендуется: sysctl net.ipv4.ip_forward=1."
|
||||||
|
|||||||
299
kaskad_store.py
299
kaskad_store.py
@@ -1,10 +1,16 @@
|
|||||||
"""Хранение правил NAT и синхронизация с iptables (chain в table nat)."""
|
"""Хранение правил NAT и синхронизация с iptables.
|
||||||
|
|
||||||
|
Режимы (KASKAD_IPTABLES_MODE):
|
||||||
|
- compat — как Kaskad PRO: DNAT в PREROUTING, INPUT/FORWARD с комментарием kaskad:PORT:proto, MASQUERADE.
|
||||||
|
- chain — отдельная цепочка nat/KASKAD_WEB (старый вариант).
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
@@ -15,6 +21,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
RULES_PATH = Path(os.environ.get("KASKAD_RULES_PATH", "/var/lib/kaskad/rules.json"))
|
RULES_PATH = Path(os.environ.get("KASKAD_RULES_PATH", "/var/lib/kaskad/rules.json"))
|
||||||
CHAIN = os.environ.get("KASKAD_NAT_CHAIN", "KASKAD_WEB").strip() or "KASKAD_WEB"
|
CHAIN = os.environ.get("KASKAD_NAT_CHAIN", "KASKAD_WEB").strip() or "KASKAD_WEB"
|
||||||
|
IPTABLES_MODE = os.environ.get("KASKAD_IPTABLES_MODE", "compat").strip().lower()
|
||||||
_PROTO_OK = frozenset({"tcp", "udp"})
|
_PROTO_OK = frozenset({"tcp", "udp"})
|
||||||
|
|
||||||
|
|
||||||
@@ -113,8 +120,241 @@ def _run_iptables(args: list[str]) -> tuple[int, str]:
|
|||||||
return 1, str(e)
|
return 1, str(e)
|
||||||
|
|
||||||
|
|
||||||
def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
def detect_out_interface() -> str | None:
|
||||||
"""Создаёт цепочку CHAIN в nat, вешает на PREROUTING, перезаполняет DNAT."""
|
env = os.environ.get("KASKAD_OUT_IFACE", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
["ip", "-4", "route", "get", "8.8.8.8"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if p.returncode != 0:
|
||||||
|
return None
|
||||||
|
m = re.search(r"\bdev\s+(\S+)", p.stdout or "")
|
||||||
|
return m.group(1) if m else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _kaskad_comment(in_port: int, proto: str) -> str:
|
||||||
|
return f"kaskad:{int(in_port)}:{proto}"
|
||||||
|
|
||||||
|
|
||||||
|
def _iptables_delete_line(table: str | None, full_line: str) -> tuple[int, str]:
|
||||||
|
"""Строка из `iptables -S`: `-A`/`-I CHAIN ...` → удаление через `-D`."""
|
||||||
|
parts = full_line.strip().split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
return 1, "bad iptables line"
|
||||||
|
op = parts[0]
|
||||||
|
if op not in ("-A", "-I"):
|
||||||
|
return 1, f"unsupported {op}"
|
||||||
|
parts[0] = "-D"
|
||||||
|
if op == "-I" and len(parts) > 3 and parts[2].isdigit():
|
||||||
|
parts.pop(2)
|
||||||
|
args = parts
|
||||||
|
if table:
|
||||||
|
args = ["-t", table] + args
|
||||||
|
return _run_iptables(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_matching_rules(table: str | None, chain: str, predicate) -> None:
|
||||||
|
for _ in range(64):
|
||||||
|
rc, out = _run_iptables(["-t", table, "-S", chain] if table else ["-S", chain])
|
||||||
|
if rc != 0:
|
||||||
|
break
|
||||||
|
prefix = f"-A {chain} "
|
||||||
|
victim = None
|
||||||
|
for line in (out or "").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith(prefix):
|
||||||
|
continue
|
||||||
|
rest = line[len(prefix) :]
|
||||||
|
if predicate(rest):
|
||||||
|
victim = line
|
||||||
|
break
|
||||||
|
if not victim:
|
||||||
|
break
|
||||||
|
rc2, err = _iptables_delete_line(table, victim)
|
||||||
|
if rc2 != 0:
|
||||||
|
log.debug("iptables delete fail: %s", err)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def compat_remove_rule(proto: str, in_port: int) -> None:
|
||||||
|
"""Убирает DNAT + INPUT/FORWARD как в Kaskad PRO."""
|
||||||
|
cm = _kaskad_comment(in_port, proto)
|
||||||
|
|
||||||
|
def pred_nat(rest: str) -> bool:
|
||||||
|
return (
|
||||||
|
f"-p {proto}" in rest
|
||||||
|
and f"--dport {in_port}" in rest
|
||||||
|
and "DNAT" in rest
|
||||||
|
and "--to-destination" in rest
|
||||||
|
)
|
||||||
|
|
||||||
|
_flush_matching_rules("nat", "PREROUTING", pred_nat)
|
||||||
|
|
||||||
|
def pred_filter(rest: str) -> bool:
|
||||||
|
return cm in rest
|
||||||
|
|
||||||
|
_flush_matching_rules(None, "INPUT", pred_filter)
|
||||||
|
_flush_matching_rules(None, "FORWARD", pred_filter)
|
||||||
|
|
||||||
|
|
||||||
|
def compat_apply_rule(r: dict[str, Any], iface: str) -> tuple[bool, str]:
|
||||||
|
proto = str(r["proto"])
|
||||||
|
in_port = int(r["in_port"])
|
||||||
|
out_port = int(r["out_port"])
|
||||||
|
target = str(r["target"])
|
||||||
|
cm = _kaskad_comment(in_port, proto)
|
||||||
|
|
||||||
|
compat_remove_rule(proto, in_port)
|
||||||
|
|
||||||
|
rc, err = _run_iptables(
|
||||||
|
[
|
||||||
|
"-I",
|
||||||
|
"INPUT",
|
||||||
|
"1",
|
||||||
|
"-p",
|
||||||
|
proto,
|
||||||
|
"--dport",
|
||||||
|
str(in_port),
|
||||||
|
"-m",
|
||||||
|
"comment",
|
||||||
|
"--comment",
|
||||||
|
cm,
|
||||||
|
"-j",
|
||||||
|
"ACCEPT",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"INPUT: {err}"
|
||||||
|
|
||||||
|
rc, err = _run_iptables(
|
||||||
|
[
|
||||||
|
"-t",
|
||||||
|
"nat",
|
||||||
|
"-A",
|
||||||
|
"PREROUTING",
|
||||||
|
"-p",
|
||||||
|
proto,
|
||||||
|
"--dport",
|
||||||
|
str(in_port),
|
||||||
|
"-j",
|
||||||
|
"DNAT",
|
||||||
|
"--to-destination",
|
||||||
|
f"{target}:{out_port}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"PREROUTING DNAT: {err}"
|
||||||
|
|
||||||
|
rc, err = _run_iptables(
|
||||||
|
[
|
||||||
|
"-I",
|
||||||
|
"FORWARD",
|
||||||
|
"1",
|
||||||
|
"-p",
|
||||||
|
proto,
|
||||||
|
"-d",
|
||||||
|
target,
|
||||||
|
"--dport",
|
||||||
|
str(out_port),
|
||||||
|
"-m",
|
||||||
|
"state",
|
||||||
|
"--state",
|
||||||
|
"NEW,ESTABLISHED,RELATED",
|
||||||
|
"-m",
|
||||||
|
"comment",
|
||||||
|
"--comment",
|
||||||
|
cm,
|
||||||
|
"-j",
|
||||||
|
"ACCEPT",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"FORWARD→dst: {err}"
|
||||||
|
|
||||||
|
rc, err = _run_iptables(
|
||||||
|
[
|
||||||
|
"-I",
|
||||||
|
"FORWARD",
|
||||||
|
"1",
|
||||||
|
"-p",
|
||||||
|
proto,
|
||||||
|
"-s",
|
||||||
|
target,
|
||||||
|
"--sport",
|
||||||
|
str(out_port),
|
||||||
|
"-m",
|
||||||
|
"state",
|
||||||
|
"--state",
|
||||||
|
"ESTABLISHED,RELATED",
|
||||||
|
"-m",
|
||||||
|
"comment",
|
||||||
|
"--comment",
|
||||||
|
cm,
|
||||||
|
"-j",
|
||||||
|
"ACCEPT",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"FORWARD←src: {err}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_masquerade(iface: str) -> tuple[bool, str]:
|
||||||
|
rc, _ = _run_iptables(["-t", "nat", "-C", "POSTROUTING", "-o", iface, "-j", "MASQUERADE"])
|
||||||
|
if rc == 0:
|
||||||
|
return True, ""
|
||||||
|
rc2, err = _run_iptables(["-t", "nat", "-A", "POSTROUTING", "-o", iface, "-j", "MASQUERADE"])
|
||||||
|
if rc2 != 0:
|
||||||
|
return False, err
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _save_persistent() -> None:
|
||||||
|
if shutil.which("netfilter-persistent"):
|
||||||
|
subprocess.run(
|
||||||
|
["netfilter-persistent", "save"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
elif shutil.which("iptables-save") and Path("/etc/init.d/iptables").is_file():
|
||||||
|
subprocess.run(["service", "iptables", "save"], capture_output=True, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_iptables_compat(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
||||||
|
iface = detect_out_interface()
|
||||||
|
if not iface:
|
||||||
|
return False, "не удалось определить исходящий интерфейс (задайте KASKAD_OUT_IFACE)"
|
||||||
|
|
||||||
|
old_disk = load_rules()
|
||||||
|
for r in old_disk:
|
||||||
|
compat_remove_rule(str(r["proto"]), int(r["in_port"]))
|
||||||
|
|
||||||
|
for r in rules:
|
||||||
|
ok, msg = compat_apply_rule(r, iface)
|
||||||
|
if not ok:
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
ok_m, msg_m = _ensure_masquerade(iface)
|
||||||
|
if not ok_m:
|
||||||
|
return False, f"MASQUERADE: {msg_m}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_save_persistent()
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("persistent save: %s", e)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def sync_iptables_chain(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
||||||
exe = _iptables_bin()
|
exe = _iptables_bin()
|
||||||
if not exe:
|
if not exe:
|
||||||
return False, "iptables недоступен (нет бинарника). Для Docker нужен образ с iptables и права NET_ADMIN."
|
return False, "iptables недоступен (нет бинарника). Для Docker нужен образ с iptables и права NET_ADMIN."
|
||||||
@@ -125,7 +365,7 @@ def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
|||||||
if "exists" not in el:
|
if "exists" not in el:
|
||||||
return False, f"не удалось создать цепочку {CHAIN}: {err}"
|
return False, f"не удалось создать цепочку {CHAIN}: {err}"
|
||||||
|
|
||||||
rc, out = _run_iptables(["-t", "nat", "-C", "PREROUTING", "-j", CHAIN])
|
rc, _out = _run_iptables(["-t", "nat", "-C", "PREROUTING", "-j", CHAIN])
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
rc2, err2 = _run_iptables(["-t", "nat", "-I", "PREROUTING", "1", "-j", CHAIN])
|
rc2, err2 = _run_iptables(["-t", "nat", "-I", "PREROUTING", "1", "-j", CHAIN])
|
||||||
if rc2 != 0:
|
if rc2 != 0:
|
||||||
@@ -164,33 +404,58 @@ def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
|
||||||
|
if IPTABLES_MODE == "chain":
|
||||||
|
return sync_iptables_chain(rules)
|
||||||
|
return sync_iptables_compat(rules)
|
||||||
|
|
||||||
|
|
||||||
def iptables_chain_dump() -> str:
|
def iptables_chain_dump() -> str:
|
||||||
exe = _iptables_bin()
|
exe = _iptables_bin()
|
||||||
if not exe:
|
if not exe:
|
||||||
return "(iptables недоступен)"
|
return "(iptables недоступен)"
|
||||||
try:
|
try:
|
||||||
p = subprocess.run(
|
if IPTABLES_MODE == "chain":
|
||||||
[exe, "-t", "nat", "-S", CHAIN],
|
p = subprocess.run(
|
||||||
capture_output=True,
|
[exe, "-t", "nat", "-S", CHAIN],
|
||||||
text=True,
|
capture_output=True,
|
||||||
timeout=15,
|
text=True,
|
||||||
)
|
timeout=15,
|
||||||
out = (p.stdout or "").strip()
|
)
|
||||||
err = (p.stderr or "").strip()
|
out = (p.stdout or "").strip()
|
||||||
if p.returncode != 0:
|
err = (p.stderr or "").strip()
|
||||||
return err or out or f"(цепочка {CHAIN}: не создана или недоступна)"
|
if p.returncode != 0:
|
||||||
return out if out else f"(цепочка {CHAIN} пуста)"
|
return err or out or f"(цепочка {CHAIN}: не создана или недоступна)"
|
||||||
|
return out if out else f"(цепочка {CHAIN} пуста)"
|
||||||
|
|
||||||
|
chunks: list[str] = []
|
||||||
|
for label, args in [
|
||||||
|
("# nat PREROUTING (DNAT)", ["-t", "nat", "-S", "PREROUTING"]),
|
||||||
|
("# filter INPUT (kaskad)", ["-S", "INPUT"]),
|
||||||
|
("# filter FORWARD (kaskad)", ["-S", "FORWARD"]),
|
||||||
|
("# nat POSTROUTING (MASQUERADE)", ["-t", "nat", "-S", "POSTROUTING"]),
|
||||||
|
]:
|
||||||
|
p = subprocess.run([exe, *args], capture_output=True, text=True, timeout=15)
|
||||||
|
body = (p.stdout or "").strip()
|
||||||
|
lines = [
|
||||||
|
ln
|
||||||
|
for ln in body.splitlines()
|
||||||
|
if "kaskad:" in ln or ("DNAT" in ln and "PREROUTING" in args[2])
|
||||||
|
or ("MASQUERADE" in ln and "POSTROUTING" in args[2])
|
||||||
|
]
|
||||||
|
chunks.append(label)
|
||||||
|
chunks.append("\n".join(lines) if lines else "(нет совпадений)")
|
||||||
|
return "\n".join(chunks)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"(ошибка: {e})"
|
return f"(ошибка: {e})"
|
||||||
|
|
||||||
|
|
||||||
def startup_resync() -> None:
|
def startup_resync() -> None:
|
||||||
"""Поднять iptables из файла при старте воркера."""
|
|
||||||
try:
|
try:
|
||||||
rules = load_rules()
|
rules = load_rules()
|
||||||
ok, msg = sync_iptables(rules)
|
ok, msg = sync_iptables(rules)
|
||||||
if ok:
|
if ok:
|
||||||
log.info("iptables синхронизированы, правил: %s", len(rules))
|
log.info("iptables синхронизированы (%s), правил: %s", IPTABLES_MODE, len(rules))
|
||||||
else:
|
else:
|
||||||
log.warning("iptables не синхронизированы: %s", msg)
|
log.warning("iptables не синхронизированы: %s", msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -143,9 +143,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadAll() {
|
function loadAll() {
|
||||||
return Promise.all([loadSystem(), loadServices(), loadClients()]).catch(function (e) {
|
return fetch("/api/meta", cred)
|
||||||
console.warn(e);
|
.then(function (r) {
|
||||||
});
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (m) {
|
||||||
|
if (m.needs_setup) {
|
||||||
|
window.location.assign("/setup");
|
||||||
|
return Promise.reject(new Error("setup"));
|
||||||
|
}
|
||||||
|
return Promise.all([loadSystem(), loadServices(), loadClients()]);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (e && e.message === "setup") return;
|
||||||
|
console.warn(e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var overlay = document.getElementById("modal-overlay");
|
var overlay = document.getElementById("modal-overlay");
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ a {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-error {
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
color: #fecaca;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay[hidden] {
|
.modal-overlay[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<title>{{ ui_title }}</title>
|
<title>{{ ui_title }}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<base href="/">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell shell-wide">
|
<div class="shell shell-wide">
|
||||||
@@ -87,7 +88,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
{% if iptables_mode == 'chain' %}
|
||||||
Kaskad Web UI · <a href="https://github.com/andrey271192/kaskad_web_vpn">kaskad_web_vpn</a> · HTTP Basic Auth · DNAT в цепочке <span class="mono">KASKAD_WEB</span> (nat).
|
Kaskad Web UI · <a href="https://github.com/andrey271192/kaskad_web_vpn">kaskad_web_vpn</a> · HTTP Basic Auth · DNAT в цепочке <span class="mono">KASKAD_WEB</span> (nat).
|
||||||
|
{% else %}
|
||||||
|
Kaskad Web UI · <a href="https://github.com/andrey271192/kaskad_web_vpn">kaskad_web_vpn</a> · HTTP Basic Auth · режим как у Kaskad PRO: DNAT в <span class="mono">PREROUTING</span>, метки <span class="mono">kaskad:PORT:proto</span> в INPUT/FORWARD, <span class="mono">MASQUERADE</span> на исходящем интерфейсе (<span class="mono">KASKAD_IPTABLES_MODE=compat</span>).
|
||||||
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +121,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='dashboard.js') }}" defer></script>
|
<script src="/static/dashboard.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
37
templates/setup.html
Normal file
37
templates/setup.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Первый вход — пароль</title>
|
||||||
|
<base href="/">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell shell-wide">
|
||||||
|
<div class="card" style="max-width:26rem;margin:2rem auto;">
|
||||||
|
<h1 class="product-title" style="margin-bottom:0.5rem;">Первый вход</h1>
|
||||||
|
<p class="muted">Задайте логин и пароль для HTTP Basic Auth (в браузере запросит их при открытии панели).</p>
|
||||||
|
{% if error %}
|
||||||
|
<p class="setup-error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" class="form-grid" style="grid-template-columns:1fr;">
|
||||||
|
<label>Логин
|
||||||
|
<input name="username" value="{{ default_user }}" required maxlength="64" autocomplete="username">
|
||||||
|
</label>
|
||||||
|
<label>Пароль (мин. 8 символов)
|
||||||
|
<input name="password" type="password" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>Пароль ещё раз
|
||||||
|
<input name="password2" type="password" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" style="justify-self:start;margin-top:0.25rem;">Сохранить и перейти к панели</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:1rem;font-size:0.78rem;">Данные сохраняются в томе <span class="mono">/var/lib/kaskad/web_auth.json</span>. При указании <span class="mono">ADMIN_PASSWORD</span> или <span class="mono">BASIC_AUTH_PASSWORD</span> в Docker этот шаг пропускается.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user