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 .
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 static ./static

110
README.md
View File

@@ -1,86 +1,89 @@
# 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)
## Важно про 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).
- Доступ к **`docker.sock`** на хосте (по умолчанию монтируется), чтобы в таблице «Сервисы» третья строка могла показывать статус **самого контейнера** как `kaskad-web.service`.
- Linux с Docker (для NAT — host network + privileged).
- По желанию **`/var/run/docker.sock`** (чтобы в «Сервисах» отображался статус контейнера как `kaskad-web.service`).
## Сборка образа: «invalid signature» / apt не подписан
## Сборка образа: «invalid signature» / apt
Чаще всего: **битый кэш Docker BuildKit**, **прокси по HTTP**, **место на диске**, **сбитые часы** на хосте.
На хосте:
См. блок в Wiki ниже — типично кэш builder, прокси, диск, время.
```bash
date -u # время должно быть разумным
df -h /var/lib/docker # есть ли свободное место
docker builder prune -af # сбросить кэш сборки
docker builder prune -af
docker build --pull --no-cache -t kaskad-web-vpn:test .
```
Если ошибка остаётся: временно отключите HTTP-прокси для сборки (`unset http_proxy https_proxy NO_PROXY`) или собирайте на другой сети.
В образе списки APT по умолчанию переводятся на **HTTPS** (`deb.debian.org`, `security.debian.org`), чтобы обходить «порченые» HTTP-кэши.
В Dockerfile зеркала APT переведены на HTTPS.
## Установка одной командой
Порт **8088**, логин **user1**, пароль в **`/root/kaskad_web.initial-password`** (или задайте **`ADMIN_PASSWORD`**):
```bash
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo bash
```
Свой пароль и порт:
Затем откройте **`/setup`** или задайте пароль при установке:
```bash
curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \
env ADMIN_PASSWORD=аш_секрет' HOST_PORT=8443 bash
```
Панель Amnezia (опционально, ссылка на странице):
Старый режим «случайный пароль в файл»:
```bash
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` внутри процесса) |
| `KASKAD_HOST_NETWORK` | `1` | `1``--network host`; `0` — проброс `-p HOST_PORT:8088` (NAT на хост может быть недоступен) |
| `KASKAD_DATA_DIR` | `/var/lib/kaskad` | Том с `rules.json` |
| `MOUNT_DOCKER_SOCK` | `1` | Монтировать `/var/run/docker.sock` для статуса контейнера в «Сервисы» |
| `BASIC_AUTH_USER` | `user1` | Логин Basic Auth |
| `ADMIN_PASSWORD` | — | Пароль |
| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | Файл пароля при автогенерации |
| `PANEL_URL` | пусто | Доп. ссылка в шапке |
| `KASKAD_REPO` | `andrey271192/kaskad_web_vpn` | Репозиторий |
| `KASKAD_REF` | `main` | Ветка |
| `HOST_PORT` | `8088` | HTTP-порт |
| `KASKAD_HOST_NETWORK` | `1` | host network / или `-p` |
| `KASKAD_DATA_DIR` | `/var/lib/kaskad` | том данных |
| `KASKAD_IPTABLES_MODE` | `compat` | `compat` или `chain` |
| `ADMIN_PASSWORD` | — | пароль без `/setup` |
| `FORCE_RANDOM_PASSWORD` | `0` | `1` — автопароль в `PASSWORD_FILE` |
| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | при FORCE_RANDOM или чтении пароля |
| `BASIC_AUTH_USER` | `user1` | логин при использовании env-пароля |
| `PANEL_URL` | — | ссылка в шапке |
### Переменные внутри приложения (Docker `-e`)
### Переменные приложения
| Переменная | Описание |
|------------|----------|
| `BOT_TOKEN`, `BOT_CHAT_ID` | Отображаются в блоке «Система» в замаскированном виде |
| `SERVICE_UNITS` | CSV юнитов systemd для первых двух строк (по умолчанию `kaskad_bot.service,kaskad-monitor.service,kaskad-web.service`) |
| `DOCKER_WEB_CONTAINER` | Имя контейнера для третьей строки (install выставляет автоматически) |
| `DOCKER_WEB_DISPLAY_UNIT` | Подпись в таблице для этой строки (по умолчанию `kaskad-web.service`) |
| `KASKAD_UI_VERSION` | Версия в блоке «yaskad» (по умолчанию `v2.2`) |
| `KASKAD_NAT_CHAIN` | Имя цепочки в `nat` (по умолчанию `KASKAD_WEB`) |
| `BOT_TOKEN`, `BOT_CHAT_ID` | только отображение в «Системе» (маска), без бота |
| `SERVICE_UNITS`, `DOCKER_WEB_*` | таблица сервисов |
| `KASKAD_OUT_IFACE` | исходящий интерфейс для MASQUERADE |
| `WEB_AUTH_JSON` | путь к `web_auth.json` |
## Удаление контейнера
@@ -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
```
Образ:
Режим **`chain`**: удаление jump в **`KASKAD_WEB`** см. исторический README в git.
```bash
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
```
Режим **`compat`**: проще снять правила через панель (удалить клиентов) или выборочно удалять строки с **`kaskad:`** в `iptables -S INPUT/FORWARD` и соответствующие DNAT в `iptables -t nat -S PREROUTING`.
## Локально без Docker
Нужны root-права для `iptables` и установленные `iptables` / `ip`.
```bash
python3 -m venv .venv && source .venv/bin/activate
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
```
Проверка без авторизации: `GET /health`.
`GET /health` без авторизации. `GET /api/meta` — флаг `needs_setup`, режим iptables.
## API (после Basic Auth)
## API
- `GET /api/system`, `/api/services`, `/api/clients`
- `POST /api/clients`, `PUT /api/clients/<id>`, `DELETE /api/clients/<id>`
- `GET /api/iptables/raw`, `POST /api/iptables/sync`
После Basic Auth: `/api/system`, `/api/services`, `/api/clients`, CRUD клиентов, `/api/iptables/raw`.
## Лицензия

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
import functools
import logging
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 (
IPTABLES_MODE,
iptables_chain_dump,
load_rules,
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"))
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"
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.secret_key = _flask_secret_key()
def check_auth(u: str, p: str) -> bool:
if not PW:
if not auth_store.password_is_configured():
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):
@@ -59,7 +128,12 @@ def health():
@requires_auth
def index():
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):

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}"
docker build -t "$IMAGE" "$SRC"
FORCE_RANDOM_PASSWORD="${FORCE_RANDOM_PASSWORD:-0}"
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "Удаление старого контейнера ${CONTAINER}"
docker rm -f "$CONTAINER" >/dev/null
fi
AUTH_ENV_ARGS=()
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
BASIC_AUTH_PASSWORD="$(tr -d '\r\n' <"$PASSWORD_FILE")"
else
AUTH_ENV_ARGS+=( -e "BASIC_AUTH_PASSWORD=$(tr -d '\r\n' <"$PASSWORD_FILE")" )
elif [[ "$FORCE_RANDOM_PASSWORD" == "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
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
umask 077
printf '%s\n' "$BASIC_AUTH_PASSWORD" >"$PASSWORD_FILE"
echo "Сгенерирован пароль Basic Auth, сохранён в ${PASSWORD_FILE}"
fi
if [[ -z "$BASIC_AUTH_PASSWORD" ]]; then
echo "BASIC_AUTH_PASSWORD пуст — задайте ADMIN_PASSWORD или файл ${PASSWORD_FILE}" >&2
exit 1
printf '%s\n' "$GEN_PW" >"$PASSWORD_FILE"
AUTH_ENV_ARGS+=( -e "BASIC_AUTH_PASSWORD=${GEN_PW}" )
echo "Сгенерирован пароль (FORCE_RANDOM_PASSWORD=1), файл ${PASSWORD_FILE}"
else
echo "Пароль веб-панели: откройте http://<хост>:${HOST_PORT}/setup при первом запуске (том ${KASKAD_DATA_DIR})."
fi
RUN_ARGS=(
@@ -89,10 +91,12 @@ RUN_ARGS=(
-e "DOCKER_WEB_CONTAINER=${CONTAINER}"
-e "DOCKER_WEB_DISPLAY_UNIT=kaskad-web.service"
-e "BASIC_AUTH_USER=${BASIC_AUTH_USER}"
-e "BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD}"
-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
RUN_ARGS+=( -v /var/run/docker.sock:/var/run/docker.sock )
fi
@@ -114,6 +118,10 @@ docker run "${RUN_ARGS[@]}" "$IMAGE"
echo "Готово."
echo " URL: http://$(hostname -f 2>/dev/null || hostname):${HOST_PORT}/"
echo " Логин: ${BASIC_AUTH_USER}"
echo " Пароль: см. ${PASSWORD_FILE} (или переменная ADMIN_PASSWORD при установке)"
echo " Правила NAT: цепочка nat/KASKAD_WEB, данные: ${KASKAD_DATA_DIR}"
echo " Рекомендуется: sysctl net.ipv4.ip_forward=1 и корректный FORWARD в filter."
if [[ "${#AUTH_ENV_ARGS[@]}" -gt 0 ]]; then
echo " Пароль: из ADMIN_PASSWORD / ${PASSWORD_FILE} / автогенерация (см. выше)."
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
import ipaddress
import json
import logging
import os
import re
import shutil
import subprocess
import uuid
@@ -15,6 +21,7 @@ log = logging.getLogger(__name__)
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"
IPTABLES_MODE = os.environ.get("KASKAD_IPTABLES_MODE", "compat").strip().lower()
_PROTO_OK = frozenset({"tcp", "udp"})
@@ -113,8 +120,241 @@ def _run_iptables(args: list[str]) -> tuple[int, str]:
return 1, str(e)
def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
"""Создаёт цепочку CHAIN в nat, вешает на PREROUTING, перезаполняет DNAT."""
def detect_out_interface() -> str | None:
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()
if not exe:
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:
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:
rc2, err2 = _run_iptables(["-t", "nat", "-I", "PREROUTING", "1", "-j", CHAIN])
if rc2 != 0:
@@ -164,11 +404,18 @@ def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
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:
exe = _iptables_bin()
if not exe:
return "(iptables недоступен)"
try:
if IPTABLES_MODE == "chain":
p = subprocess.run(
[exe, "-t", "nat", "-S", CHAIN],
capture_output=True,
@@ -180,17 +427,35 @@ def iptables_chain_dump() -> str:
if p.returncode != 0:
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:
return f"(ошибка: {e})"
def startup_resync() -> None:
"""Поднять iptables из файла при старте воркера."""
try:
rules = load_rules()
ok, msg = sync_iptables(rules)
if ok:
log.info("iptables синхронизированы, правил: %s", len(rules))
log.info("iptables синхронизированы (%s), правил: %s", IPTABLES_MODE, len(rules))
else:
log.warning("iptables не синхронизированы: %s", msg)
except Exception as e:

View File

@@ -143,7 +143,19 @@
}
function loadAll() {
return Promise.all([loadSystem(), loadServices(), loadClients()]).catch(function (e) {
return fetch("/api/meta", cred)
.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);
});
}

View File

@@ -212,6 +212,15 @@ a {
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] {
display: none !important;
}

View File

@@ -6,8 +6,9 @@
<title>{{ ui_title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="shell shell-wide">
@@ -87,7 +88,11 @@
</section>
<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).
{% 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>
</div>
@@ -116,6 +121,6 @@
</div>
</div>
<script src="{{ url_for('static', filename='dashboard.js') }}" defer></script>
<script src="/static/dashboard.js" defer></script>
</body>
</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>