From 43daf188c2334faba005f1931728534c8f58f667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Thu, 14 May 2026 20:52:35 +0300 Subject: [PATCH] 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 --- Dockerfile | 2 +- README.md | 110 +++++++--------- app.py | 88 ++++++++++++- auth_store.py | 60 +++++++++ install.sh | 40 +++--- kaskad_store.py | 299 ++++++++++++++++++++++++++++++++++++++++--- static/dashboard.js | 18 ++- static/styles.css | 9 ++ templates/index.html | 9 +- templates/setup.html | 37 ++++++ 10 files changed, 565 insertions(+), 107 deletions(-) create mode 100644 auth_store.py create mode 100644 templates/setup.html diff --git a/Dockerfile b/Dockerfile index d679621..d5f82b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index d036638..a957251 100644 --- a/README.md +++ b/README.md @@ -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/`, `DELETE /api/clients/` -- `GET /api/iptables/raw`, `POST /api/iptables/sync` +После Basic Auth: `/api/system`, `/api/services`, `/api/clients`, CRUD клиентов, `/api/iptables/raw`. ## Лицензия diff --git a/app.py b/app.py index 7fdee42..9a30f19 100644 --- a/app.py +++ b/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 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): diff --git a/auth_store.py b/auth_store.py new file mode 100644 index 0000000..ab9788f --- /dev/null +++ b/auth_store.py @@ -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) diff --git a/install.sh b/install.sh index c2f36c2..8bc4669 100755 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/kaskad_store.py b/kaskad_store.py index 640d0bc..41ea87a 100644 --- a/kaskad_store.py +++ b/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 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,33 +404,58 @@ 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: - p = subprocess.run( - [exe, "-t", "nat", "-S", CHAIN], - capture_output=True, - text=True, - timeout=15, - ) - out = (p.stdout or "").strip() - err = (p.stderr or "").strip() - if p.returncode != 0: - return err or out or f"(цепочка {CHAIN}: не создана или недоступна)" - return out if out else f"(цепочка {CHAIN} пуста)" + if IPTABLES_MODE == "chain": + p = subprocess.run( + [exe, "-t", "nat", "-S", CHAIN], + capture_output=True, + text=True, + timeout=15, + ) + out = (p.stdout or "").strip() + err = (p.stderr or "").strip() + 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: diff --git a/static/dashboard.js b/static/dashboard.js index 54e18ee..b302f8d 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -143,9 +143,21 @@ } function loadAll() { - return Promise.all([loadSystem(), loadServices(), loadClients()]).catch(function (e) { - console.warn(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); + }); } var overlay = document.getElementById("modal-overlay"); diff --git a/static/styles.css b/static/styles.css index 98c3f3b..e7ee22b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } diff --git a/templates/index.html b/templates/index.html index ce18e3c..e876273 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,8 +6,9 @@ {{ ui_title }} + - +
@@ -87,7 +88,11 @@
+ {% if iptables_mode == 'chain' %} Kaskad Web UI · kaskad_web_vpn · HTTP Basic Auth · DNAT в цепочке KASKAD_WEB (nat). + {% else %} + Kaskad Web UI · kaskad_web_vpn · HTTP Basic Auth · режим как у Kaskad PRO: DNAT в PREROUTING, метки kaskad:PORT:proto в INPUT/FORWARD, MASQUERADE на исходящем интерфейсе (KASKAD_IPTABLES_MODE=compat). + {% endif %}
@@ -116,6 +121,6 @@ - + diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..0b88c7d --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,37 @@ + + + + + + Первый вход — пароль + + + + + + + +
+
+

Первый вход

+

Задайте логин и пароль для HTTP Basic Auth (в браузере запросит их при открытии панели).

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + + +
+

Данные сохраняются в томе /var/lib/kaskad/web_auth.json. При указании ADMIN_PASSWORD или BASIC_AUTH_PASSWORD в Docker этот шаг пропускается.

+
+
+ +