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:
Андрей Бобырев
2026-05-14 20:52:35 +03:00
parent a579612c9a
commit 43daf188c2
10 changed files with 565 additions and 107 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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)

View File

@@ -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."

View File

@@ -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:

View File

@@ -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");

View File

@@ -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;
} }

View File

@@ -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
View 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>