mirror of
https://github.com/andrey271192/kaskad_web_vpn.git
synced 2026-09-20 11:55:35 +00:00
feat: first-run /setup password + PRO-style iptables
compat DNAT/INPUT/FORWARD/MASQUERADE; optional chain mode; auth_store hash; static absolute URLs; install skips env password by default; README. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,7 +33,7 @@ RUN set -eux; \
|
||||
COPY requirements.txt .
|
||||
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
110
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/<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
88
app.py
@@ -1,13 +1,17 @@
|
||||
"""Kaskad Web UI v2 — Flask + Basic Auth + NAT CRUD."""
|
||||
"""Kaskad Web UI v2 — Flask, первичная настройка пароля, NAT CRUD."""
|
||||
from __future__ import annotations
|
||||
|
||||
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
60
auth_store.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Пароль веб-интерфейса: переменная окружения или файл после первого входа."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
AUTH_JSON = Path(os.environ.get("WEB_AUTH_JSON", "/var/lib/kaskad/web_auth.json"))
|
||||
DEFAULT_USER = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1"
|
||||
|
||||
|
||||
def ensure_auth_dir() -> None:
|
||||
AUTH_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def auth_from_env() -> bool:
|
||||
return bool(os.environ.get("BASIC_AUTH_PASSWORD", "").strip())
|
||||
|
||||
|
||||
def load_record() -> dict | None:
|
||||
if not AUTH_JSON.is_file():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(AUTH_JSON.read_text(encoding="utf-8"))
|
||||
return raw if isinstance(raw, dict) else None
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def password_is_configured() -> bool:
|
||||
if auth_from_env():
|
||||
return True
|
||||
rec = load_record()
|
||||
return bool(rec and rec.get("password_hash"))
|
||||
|
||||
|
||||
def verify_credentials(username: str, password: str) -> bool:
|
||||
if auth_from_env():
|
||||
u = os.environ.get("BASIC_AUTH_USER", "user1").strip() or "user1"
|
||||
epw = os.environ.get("BASIC_AUTH_PASSWORD", "").strip()
|
||||
return username == u and password == epw
|
||||
|
||||
rec = load_record()
|
||||
if not rec or not rec.get("password_hash"):
|
||||
return False
|
||||
u = str(rec.get("user") or DEFAULT_USER)
|
||||
return username == u and check_password_hash(str(rec["password_hash"]), password)
|
||||
|
||||
|
||||
def save_password(username: str, plaintext: str) -> None:
|
||||
ensure_auth_dir()
|
||||
h = generate_password_hash(plaintext)
|
||||
tmp = AUTH_JSON.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"user": username.strip() or DEFAULT_USER, "password_hash": h}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(AUTH_JSON)
|
||||
40
install.sh
40
install.sh
@@ -54,29 +54,31 @@ mv "$EXTRACTED"/* "$SRC"/
|
||||
echo "Сборка образа ${IMAGE} …"
|
||||
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."
|
||||
|
||||
277
kaskad_store.py
277
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,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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
37
templates/setup.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Первый вход — пароль</title>
|
||||
<base href="/">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell shell-wide">
|
||||
<div class="card" style="max-width:26rem;margin:2rem auto;">
|
||||
<h1 class="product-title" style="margin-bottom:0.5rem;">Первый вход</h1>
|
||||
<p class="muted">Задайте логин и пароль для HTTP Basic Auth (в браузере запросит их при открытии панели).</p>
|
||||
{% if error %}
|
||||
<p class="setup-error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" class="form-grid" style="grid-template-columns:1fr;">
|
||||
<label>Логин
|
||||
<input name="username" value="{{ default_user }}" required maxlength="64" autocomplete="username">
|
||||
</label>
|
||||
<label>Пароль (мин. 8 символов)
|
||||
<input name="password" type="password" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<label>Пароль ещё раз
|
||||
<input name="password2" type="password" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="btn" style="justify-self:start;margin-top:0.25rem;">Сохранить и перейти к панели</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:1rem;font-size:0.78rem;">Данные сохраняются в томе <span class="mono">/var/lib/kaskad/web_auth.json</span>. При указании <span class="mono">ADMIN_PASSWORD</span> или <span class="mono">BASIC_AUTH_PASSWORD</span> в Docker этот шаг пропускается.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user