diff --git a/Dockerfile b/Dockerfile index 2f58ea6..3a21cd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,21 @@ +FROM docker:27-cli AS dockercli + FROM python:3.12-slim-bookworm +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker + WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends iptables iproute2 \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY app.py . +COPY app.py kaskad_store.py system_info.py . COPY templates ./templates COPY static ./static diff --git a/README.md b/README.md index 576b7bd..da03b78 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,25 @@ -# kaskad_web_vpn +# kaskad_web_vpn — Kaskad Web UI v2 -Мини-сайт на Flask с **HTTP Basic Auth**: памятка про каскад **AmneziaWG**, без сторонней рекламы. В шапке — ссылки автора на поддержку (GitHub, Boosty, Ozon СБП, Telegram). +Веб-панель на **Flask** с **HTTP Basic Auth**: дашборд как у классического «каскада» — блок **Система**, таблица **Сервисы**, CRUD **клиентов (NAT / DNAT)** и просмотр цепочки **iptables**. В шапке — ваши ссылки на поддержку (GitHub, Boosty, Ozon СБП, Telegram), без чужой рекламы в подвале. Репозиторий: [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`** (настраиваются на хосте отдельно). + +По умолчанию установка поднимает контейнер с **`--network host`** и **`--privileged`**, чтобы **iptables менял таблицы самого хоста**. Режим только с `-p порт:8088` без host network применим для просмотра UI, но DNAT с контейнера на хост в таком виде обычно **не** используют. + +Данные правил: **`KASKAD_DATA_DIR`** на хосте → `/var/lib/kaskad/rules.json` в контейнере. + ## Требования -- Docker с доступом для текущего пользователя (или запуск скриптов от root). +- Linux с Docker (для NAT — предпочтительно host network + privileged). +- Доступ к **`docker.sock`** на хосте (по умолчанию монтируется), чтобы в таблице «Сервисы» третья строка могла показывать статус **самого контейнера** как `kaskad-web.service`. ## Установка одной командой -На сервере (порт хоста по умолчанию **8088**, логин **user1**, пароль генерируется и пишется в `/root/kaskad_web.initial-password`): +Порт **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 @@ -23,49 +32,78 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/in env ADMIN_PASSWORD='ваш_секрет' HOST_PORT=8443 bash ``` -Опционально ссылка на вашу веб-панель Amnezia (подставится на страницу): +Панель Amnezia (опционально, ссылка на странице): ```bash curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/install.sh | sudo \ env PANEL_URL='https://panel.example.com' bash ``` -Переменные окружения установки: +### Переменные установки (install.sh) | Переменная | По умолчанию | Описание | |------------|--------------|----------| -| `HOST_PORT` | `8088` | Порт на хосте (`HOST_PORT:8088` в контейнере) | +| `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` | пусто | URL панели для блока на главной | -| `KASKAD_REPO` | `andrey271192/kaskad_web_vpn` | Репозиторий GitHub | +| `ADMIN_PASSWORD` | — | Пароль | +| `PASSWORD_FILE` | `/root/kaskad_web.initial-password` | Файл пароля при автогенерации | +| `PANEL_URL` | пусто | Доп. ссылка в шапке | +| `KASKAD_REPO` | `andrey271192/kaskad_web_vpn` | Репозиторий | | `KASKAD_REF` | `main` | Ветка | -| `KASKAD_INSTALL_ROOT` | `/opt` | Куда распаковать исходники для сборки | -## Удаление +### Переменные внутри приложения (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`) | + +## Удаление контейнера ```bash curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad_web_vpn/main/uninstall.sh | sudo bash ``` -Удалить ещё и Docker-образ: +Образ: ```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 +``` + ## Локально без Docker +Нужны root-права для `iptables` и установленные `iptables` / `ip`. + ```bash python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt export BASIC_AUTH_PASSWORD='secret' -flask --app app run --host 0.0.0.0 --port 8088 +sudo -E env PATH="$PATH" flask --app app run --host 0.0.0.0 --port 8088 ``` Проверка без авторизации: `GET /health`. +## API (после Basic Auth) + +- `GET /api/system`, `/api/services`, `/api/clients` +- `POST /api/clients`, `PUT /api/clients/`, `DELETE /api/clients/` +- `GET /api/iptables/raw`, `POST /api/iptables/sync` + ## Лицензия MIT diff --git a/app.py b/app.py index b0de062..7fdee42 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,24 @@ -"""Мини-сайт про каскад AmneziaWG: только страница и HTTP Basic Auth.""" +"""Kaskad Web UI v2 — Flask + Basic Auth + NAT CRUD.""" from __future__ import annotations import functools +import logging import os -from flask import Flask, Response, render_template, request +from flask import Flask, Response, jsonify, render_template, request + +from kaskad_store import ( + iptables_chain_dump, + load_rules, + normalize_rule, + save_rules, + startup_resync, + sync_iptables, +) +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() @@ -44,7 +58,107 @@ def health(): @app.get("/") @requires_auth def index(): - return render_template("index.html", panel_url=PANEL_URL) + title = os.environ.get("KASKAD_UI_TITLE", "Kaskad Web UI v2").strip() + return render_template("index.html", panel_url=PANEL_URL, ui_title=title) + + +def _need_auth_json(view): + @functools.wraps(view) + def wrapped(*args, **kwargs): + auth = request.authorization + if auth is None or not check_auth(auth.username or "", auth.password or ""): + return jsonify({"error": "unauthorized"}), 401 + return view(*args, **kwargs) + + return wrapped + + +@app.get("/api/system") +@_need_auth_json +def api_system(): + return jsonify(collect_system_payload()) + + +@app.get("/api/services") +@_need_auth_json +def api_services(): + return jsonify({"services": collect_services_rows()}) + + +@app.get("/api/clients") +@_need_auth_json +def api_clients_list(): + return jsonify({"clients": load_rules()}) + + +@app.post("/api/clients") +@_need_auth_json +def api_clients_add(): + body = request.get_json(force=True, silent=True) or {} + try: + rule = normalize_rule(body) + rules = load_rules() + rules.append(rule) + ok, msg = sync_iptables(rules) + if not ok: + return jsonify({"error": msg}), 500 + save_rules(rules) + return jsonify({"client": rule}), 201 + except (KeyError, TypeError, ValueError) as e: + return jsonify({"error": str(e)}), 400 + + +@app.put("/api/clients/") +@_need_auth_json +def api_clients_update(cid: str): + body = request.get_json(force=True, silent=True) or {} + rules = load_rules() + idx = next((i for i, r in enumerate(rules) if str(r.get("id")) == cid), None) + if idx is None: + return jsonify({"error": "не найдено"}), 404 + try: + rule = normalize_rule(body, rid=cid) + rules[idx] = rule + ok, msg = sync_iptables(rules) + if not ok: + return jsonify({"error": msg}), 500 + save_rules(rules) + return jsonify({"client": rule}) + except (KeyError, TypeError, ValueError) as e: + return jsonify({"error": str(e)}), 400 + + +@app.delete("/api/clients/") +@_need_auth_json +def api_clients_delete(cid: str): + rules = load_rules() + new_rules = [r for r in rules if str(r.get("id")) != cid] + if len(new_rules) == len(rules): + return jsonify({"error": "не найдено"}), 404 + ok, msg = sync_iptables(new_rules) + if not ok: + return jsonify({"error": msg}), 500 + save_rules(new_rules) + return jsonify({"ok": True}) + + +@app.post("/api/iptables/sync") +@_need_auth_json +def api_iptables_sync(): + rules = load_rules() + ok, msg = sync_iptables(rules) + if not ok: + return jsonify({"error": msg}), 500 + return jsonify({"ok": True}) + + +@app.get("/api/iptables/raw") +@_need_auth_json +def api_iptables_raw(): + return jsonify({"raw": iptables_chain_dump()}) + + +startup_resync() def main(): diff --git a/install.sh b/install.sh index 29b7e9f..c2f36c2 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Однострочная установка (пример в README): клон/скачивание с GitHub, сборка образа, запуск контейнера. +# Однострочная установка: GitHub → сборка образа → контейнер с NAT на хосте (privileged + host network). set -euo pipefail REPO="${KASKAD_REPO:-andrey271192/kaskad_web_vpn}" @@ -12,6 +12,9 @@ BASIC_AUTH_USER="${BASIC_AUTH_USER:-user1}" BASIC_AUTH_REALM="${BASIC_AUTH_REALM:-kaskad}" PASSWORD_FILE="${PASSWORD_FILE:-/root/kaskad_web.initial-password}" PANEL_URL="${PANEL_URL:-}" +KASKAD_DATA_DIR="${KASKAD_DATA_DIR:-/var/lib/kaskad}" +KASKAD_HOST_NETWORK="${KASKAD_HOST_NETWORK:-1}" +MOUNT_DOCKER_SOCK="${MOUNT_DOCKER_SOCK:-1}" need_cmd() { command -v "$1" >/dev/null 2>&1 || { @@ -27,6 +30,8 @@ if ! docker info >/dev/null 2>&1; then exit 1 fi +mkdir -p "$KASKAD_DATA_DIR" + SRC="${INSTALL_ROOT}/kaskad_web_vpn_src" mkdir -p "$(dirname "$SRC")" @@ -78,14 +83,30 @@ RUN_ARGS=( -d --name "$CONTAINER" --restart unless-stopped - -p "${HOST_PORT}:8088" + --privileged + -v "${KASKAD_DATA_DIR}:/var/lib/kaskad" + -e "KASKAD_RULES_PATH=/var/lib/kaskad/rules.json" + -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}" ) +if [[ "${MOUNT_DOCKER_SOCK:-1}" == "1" ]] && [[ -S /var/run/docker.sock ]]; then + RUN_ARGS+=( -v /var/run/docker.sock:/var/run/docker.sock ) +fi + if [[ -n "$PANEL_URL" ]]; then - RUN_ARGS+=(-e "PANEL_URL=${PANEL_URL}") + RUN_ARGS+=( -e "PANEL_URL=${PANEL_URL}" ) +fi + +if [[ "$KASKAD_HOST_NETWORK" == "1" ]]; then + RUN_ARGS+=( --network host ) + RUN_ARGS+=( -e "PORT=${HOST_PORT}" ) +else + RUN_ARGS+=( -p "${HOST_PORT}:8088" ) + RUN_ARGS+=( -e "PORT=8088" ) fi docker run "${RUN_ARGS[@]}" "$IMAGE" @@ -94,3 +115,5 @@ 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." diff --git a/kaskad_store.py b/kaskad_store.py new file mode 100644 index 0000000..640d0bc --- /dev/null +++ b/kaskad_store.py @@ -0,0 +1,197 @@ +"""Хранение правил NAT и синхронизация с iptables (chain в table nat).""" +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import shutil +import subprocess +import uuid +from pathlib import Path +from typing import Any + +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" +_PROTO_OK = frozenset({"tcp", "udp"}) + + +def _iptables_bin() -> str | None: + for name in ("iptables", "iptables-nft", "iptables-legacy"): + p = shutil.which(name) + if p: + return p + return None + + +def ensure_state_dir() -> None: + RULES_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def load_rules() -> list[dict[str, Any]]: + ensure_state_dir() + if not RULES_PATH.is_file(): + return [] + try: + raw = json.loads(RULES_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + log.warning("rules read failed: %s", e) + return [] + rules = raw.get("rules") if isinstance(raw, dict) else raw + if not isinstance(rules, list): + return [] + out: list[dict[str, Any]] = [] + for r in rules: + if isinstance(r, dict) and r.get("id"): + out.append(r) + return out + + +def save_rules(rules: list[dict[str, Any]]) -> None: + ensure_state_dir() + tmp = RULES_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps({"rules": rules}, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(RULES_PATH) + + +def _validate_rule(r: dict[str, Any]) -> None: + proto = str(r.get("proto", "")).lower().strip() + if proto not in _PROTO_OK: + raise ValueError("proto должен быть tcp или udp") + ipaddress.IPv4Address(str(r.get("target", ""))) + for key in ("in_port", "out_port"): + p = int(r[key]) + if not (1 <= p <= 65535): + raise ValueError(f"порт {key} вне диапазона") + user = str(r.get("user", "")).strip() + if not user or len(user) > 64: + raise ValueError("USER: 1–64 символа") + if any(ord(c) < 32 for c in user): + raise ValueError("USER: недопустимые символы") + note = str(r.get("note", "")) + where = str(r.get("where", "")) + if len(note) > 256 or len(where) > 256: + raise ValueError("заметка / где — не длиннее 256 символов") + + +def normalize_rule(body: dict[str, Any], rid: str | None = None) -> dict[str, Any]: + try: + in_p = int(body.get("in_port")) + out_p = int(body.get("out_port")) + except (TypeError, ValueError): + raise ValueError("in_port и out_port должны быть целыми числами") from None + r = { + "id": rid or uuid.uuid4().hex[:16], + "user": str(body.get("user", "")).strip(), + "proto": str(body.get("proto", "udp")).lower().strip(), + "in_port": in_p, + "target": str(body.get("target", "")).strip(), + "out_port": out_p, + "note": str(body.get("note", "")).strip(), + "where": str(body.get("where", "")).strip(), + } + _validate_rule(r) + return r + + +def _run_iptables(args: list[str]) -> tuple[int, str]: + exe = _iptables_bin() + if not exe: + return 127, "iptables не найден в PATH" + try: + p = subprocess.run( + [exe, *args], + capture_output=True, + text=True, + timeout=30, + ) + err = (p.stderr or p.stdout or "").strip() + return p.returncode, err + except Exception as e: + return 1, str(e) + + +def sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]: + """Создаёт цепочку CHAIN в nat, вешает на PREROUTING, перезаполняет DNAT.""" + exe = _iptables_bin() + if not exe: + return False, "iptables недоступен (нет бинарника). Для Docker нужен образ с iptables и права NET_ADMIN." + + rc, err = _run_iptables(["-t", "nat", "-N", CHAIN]) + if rc != 0: + el = err.lower() + if "exists" not in el: + return False, f"не удалось создать цепочку {CHAIN}: {err}" + + 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: + return False, f"не удалось привязать PREROUTING → {CHAIN}: {err2}" + + rc, ferr = _run_iptables(["-t", "nat", "-F", CHAIN]) + if rc != 0: + return False, f"flush {CHAIN}: {ferr}" + + for r in rules: + rid = str(r["id"]) + proto = str(r["proto"]) + comment = f"kaskad-{rid}" + cmd = [ + "-t", + "nat", + "-A", + CHAIN, + "-p", + proto, + "--dport", + str(int(r["in_port"])), + "-j", + "DNAT", + "--to-destination", + f'{r["target"]}:{int(r["out_port"])}', + "-m", + "comment", + "--comment", + comment, + ] + rc, emsg = _run_iptables(cmd) + if rc != 0: + return False, f"правило {rid}: {emsg}" + + return True, "" + + +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} пуста)" + 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)) + else: + log.warning("iptables не синхронизированы: %s", msg) + except Exception as e: + log.warning("startup_resync: %s", e) diff --git a/requirements.txt b/requirements.txt index 9ea9c8a..8e23e06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask==3.0.3 gunicorn==23.0.0 +psutil==6.1.0 diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..54e18ee --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,242 @@ +(function () { + const cred = { credentials: "same-origin" }; + + function api(path, opts) { + return fetch(path, Object.assign({}, cred, opts)).then(function (res) { + if (res.status === 401) { + window.location.reload(); + throw new Error("401"); + } + return res; + }); + } + + function pill(active) { + var s = (active || "").toLowerCase(); + var cls = "pill"; + if (s === "active") cls += " pill-active"; + else if (s === "inactive" || s === "missing") cls += " pill-muted"; + return '' + escapeHtml(active || "—") + ""; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function renderSystem(data) { + var g = document.getElementById("system-grid"); + var cells = [ + ["MachineId", data.machine_id], + ["Uptime", data.uptime], + ["Load", data.load], + ["Mem", data.mem], + ["IPADDR / IPv4", String(data.ipaddrs || "").replace(/\n/g, "
")], + ["yaskad", data.yaskad_version], + ["BOT_TOKEN", data.bot_token_masked], + ["BOT_CHAT_ID", data.bot_chat_masked], + ]; + g.innerHTML = cells + .map(function (kv) { + return ( + '
' + + escapeHtml(kv[0]) + + '' + + kv[1] + + "
" + ); + }) + .join(""); + } + + function renderServices(rows) { + var tb = document.querySelector("#tbl-services tbody"); + tb.innerHTML = (rows || []) + .map(function (r) { + return ( + "" + + escapeHtml(r.unit) + + "" + + pill(r.active) + + "" + + escapeHtml(r.enabled) + + "" + ); + }) + .join(""); + } + + function renderClients(list) { + var tb = document.querySelector("#tbl-clients tbody"); + tb.innerHTML = (list || []) + .map(function (c) { + return ( + "" + + escapeHtml(c.user) + + "" + + escapeHtml(c.proto) + + "" + + escapeHtml(String(c.in_port)) + + '→' + + escapeHtml(c.target) + + "" + + escapeHtml(String(c.out_port)) + + "" + + escapeHtml(c.note || "") + + "" + + escapeHtml(c.where || "") + + ' ' + ); + }) + .join(""); + + tb.querySelectorAll("tr").forEach(function (row) { + var id = row.getAttribute("data-id"); + row.querySelector(".edit").addEventListener("click", function () { + openModalEdit(id); + }); + row.querySelector(".del").addEventListener("click", function () { + if (!confirm("Удалить правило?")) return; + api("/api/clients/" + encodeURIComponent(id), { method: "DELETE" }) + .then(function (r) { + return r.json(); + }) + .then(function () { + loadClients(); + }) + .catch(function () {}); + }); + }); + } + + function loadSystem() { + return api("/api/system") + .then(function (r) { + return r.json(); + }) + .then(renderSystem); + } + + function loadServices() { + return api("/api/services") + .then(function (r) { + return r.json(); + }) + .then(function (d) { + renderServices(d.services); + }); + } + + function loadClients() { + return api("/api/clients") + .then(function (r) { + return r.json(); + }) + .then(function (d) { + renderClients(d.clients); + }); + } + + function loadAll() { + return Promise.all([loadSystem(), loadServices(), loadClients()]).catch(function (e) { + console.warn(e); + }); + } + + var overlay = document.getElementById("modal-overlay"); + var form = document.getElementById("form-client"); + + function openModalNew() { + document.getElementById("modal-title").textContent = "Новый клиент"; + form.reset(); + document.getElementById("f-id").value = ""; + overlay.hidden = false; + } + + function openModalEdit(id) { + api("/api/clients") + .then(function (r) { + return r.json(); + }) + .then(function (d) { + var c = (d.clients || []).find(function (x) { + return x.id === id; + }); + if (!c) return; + document.getElementById("modal-title").textContent = "Изменить клиента"; + document.getElementById("f-id").value = c.id; + document.getElementById("f-user").value = c.user; + document.getElementById("f-proto").value = c.proto; + document.getElementById("f-in").value = c.in_port; + document.getElementById("f-target").value = c.target; + document.getElementById("f-out").value = c.out_port; + document.getElementById("f-note").value = c.note || ""; + document.getElementById("f-where").value = c.where || ""; + overlay.hidden = false; + }); + } + + function closeModal() { + overlay.hidden = true; + } + + form.addEventListener("submit", function (ev) { + ev.preventDefault(); + var fd = new FormData(form); + var body = { + user: fd.get("user"), + proto: fd.get("proto"), + in_port: parseInt(fd.get("in_port"), 10), + target: fd.get("target"), + out_port: parseInt(fd.get("out_port"), 10), + note: fd.get("note") || "", + where: fd.get("where") || "", + }; + var cid = (fd.get("id") || "").toString().trim(); + var url = cid ? "/api/clients/" + encodeURIComponent(cid) : "/api/clients"; + var method = cid ? "PUT" : "POST"; + api(url, { + method: method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (r) { + return r.json().then(function (j) { + if (!r.ok) throw new Error(j.error || r.status); + return j; + }); + }) + .then(function () { + closeModal(); + loadClients(); + }) + .catch(function (e) { + alert(e.message || String(e)); + }); + }); + + document.getElementById("modal-cancel").addEventListener("click", closeModal); + overlay.addEventListener("click", function (ev) { + if (ev.target === overlay) closeModal(); + }); + + document.getElementById("btn-add-client").addEventListener("click", openModalNew); + document.getElementById("btn-refresh").addEventListener("click", loadAll); + + document.getElementById("btn-load-iptables").addEventListener("click", function () { + api("/api/iptables/raw") + .then(function (r) { + return r.json(); + }) + .then(function (d) { + document.getElementById("iptables-out").textContent = d.raw || ""; + }); + }); + + loadAll(); +})(); diff --git a/static/styles.css b/static/styles.css index aa753b3..98c3f3b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -32,6 +32,249 @@ a { padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 3rem; } +.shell-wide { + max-width: min(1180px, 100%); +} + +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.product-title { + font-size: clamp(1.15rem, 3vw, 1.45rem); + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; +} + +.btn-ghost { + background: transparent; +} + +.btn-sm { + padding: 0.35rem 0.65rem; + font-size: 0.82rem; +} + +.card { + margin-top: 1rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + padding: 1rem 1.1rem; +} + +.card-title { + margin: 0 0 0.75rem; + font-size: 1.05rem; + font-weight: 700; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.card-head .card-title { + margin: 0; +} + +.sys-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.65rem; +} + +.sys-cell { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.55rem 0.65rem; + background: #0a0f16; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sys-k { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.sys-v { + font-size: 0.88rem; + word-break: break-word; +} + +.table-wrap { + overflow-x: auto; +} + +.table-scroll { + max-width: 100%; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.86rem; +} + +.data-table th, +.data-table td { + border: 1px solid var(--line); + padding: 0.45rem 0.55rem; + text-align: left; + vertical-align: middle; +} + +.data-table thead th { + background: rgba(0, 0, 0, 0.25); + color: var(--muted); + font-weight: 600; +} + +.data-table tbody tr:hover { + background: rgba(125, 211, 252, 0.04); +} + +.pill { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 999px; + font-size: 0.78rem; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.25); +} + +.pill-active { + border-color: rgba(52, 211, 153, 0.45); + background: rgba(52, 211, 153, 0.12); + color: #6ee7b7; +} + +.pill-muted { + color: var(--muted); +} + +.arrow-cell { + text-align: center; + color: var(--muted); + width: 2rem; +} + +.row-actions { + white-space: nowrap; +} + +.icon-btn { + background: transparent; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 1rem; + padding: 0 0.15rem; +} + +.icon-btn.del { + color: #f87171; +} + +.panel-fold.card-inner { + margin-top: 1rem; +} + +.mono-block { + margin-top: 0.65rem; + padding: 0.75rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "JetBrains Mono", monospace; + font-size: 0.78rem; + color: #cbd5f5; + max-height: 22rem; + overflow: auto; +} + +.pad-v { + margin: 0; +} + +.modal-overlay[hidden] { + display: none !important; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 50; +} + +.modal { + width: min(480px, 100%); + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1rem 1.15rem; +} + +.modal h3 { + margin: 0 0 0.75rem; + font-size: 1.05rem; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.55rem 0.75rem; +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.78rem; + color: var(--muted); +} + +.form-grid input, +.form-grid select { + padding: 0.45rem 0.5rem; + border-radius: 8px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; +} + +.span-2 { + grid-column: span 2; +} + +.modal-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.35rem; +} + .site-header { border: 1px solid var(--line); border-radius: 14px; @@ -90,7 +333,8 @@ h1 { overflow: hidden; } -.panel-fold > summary { +.panel-fold > summary, +.panel-fold .fold-sum { list-style: none; cursor: pointer; padding: 0.85rem 1rem; @@ -101,14 +345,21 @@ h1 { border-bottom: 1px solid transparent; } -.panel-fold > summary::-webkit-details-marker { +.panel-fold > summary::-webkit-details-marker, +.panel-fold .fold-sum::-webkit-details-marker { display: none; } -.panel-fold[open] > summary { +.panel-fold[open] > summary, +.panel-fold[open] .fold-sum { border-bottom-color: var(--line); } +.panel-fold[open] > summary .fold-arrow, +.panel-fold[open] .fold-sum .fold-arrow { + transform: rotate(90deg); +} + .fold-arrow { width: 0; height: 0; @@ -119,10 +370,6 @@ h1 { transition: transform 0.15s ease; } -.panel-fold[open] > summary .fold-arrow { - transform: rotate(90deg); -} - .fold-h { font-weight: 700; font-size: 1rem; diff --git a/system_info.py b/system_info.py new file mode 100644 index 0000000..b8f96ee --- /dev/null +++ b/system_info.py @@ -0,0 +1,191 @@ +"""Метрики хоста Linux + systemd / docker (best-effort).""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import psutil + + +def _read_machine_id() -> str: + for p in (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id")): + try: + t = p.read_text(encoding="utf-8").strip() + if t: + return t + except OSError: + continue + return "—" + + +def _uptime_h() -> str: + try: + up = float(Path("/proc/uptime").read_text().split()[0]) + d, rem = divmod(int(up), 86400) + h, rem = divmod(rem, 3600) + m, _ = divmod(rem, 60) + parts = [] + if d: + parts.append(f"{d} дн.") + if h: + parts.append(f"{h} ч.") + parts.append(f"{m} мин.") + return "up " + ", ".join(parts) + except Exception: + return "—" + + +def _load_avg() -> str: + try: + la = os.getloadavg() + return ", ".join(f"{x:.2f}" for x in la) + except Exception: + return "—" + + +def _mem_mb() -> str: + try: + v = psutil.virtual_memory() + used = int(v.used / (1024 * 1024)) + total = int(v.total / (1024 * 1024)) + return f"{used} / {total} MB" + except Exception: + return "—" + + +def _ipv4_addrs() -> str: + lines: list[str] = [] + try: + out = subprocess.run( + ["ip", "-4", "-br", "addr"], + capture_output=True, + text=True, + timeout=5, + ) + for raw in (out.stdout or "").splitlines(): + raw = raw.strip() + if not raw: + continue + parts = raw.split() + if len(parts) >= 3: + iface = parts[0] + cidrs = [x for x in parts[2:] if "/" in x] + if cidrs: + lines.append(f"{iface}: {', '.join(cidrs)}") + except Exception: + pass + if not lines: + try: + addrs = [] + for n, addrs_list in psutil.net_if_addrs().items(): + for a in addrs_list: + if a.family.name == "AF_INET" and not a.address.startswith("127."): + addrs.append(f"{n}: {a.address}") + lines = addrs + except Exception: + pass + return "\n".join(lines) if lines else "—" + + +def mask_secret(val: str, keep_start: int = 4, keep_end: int = 2) -> str: + if not val: + return "—" + if len(val) <= keep_start + keep_end: + return "***" + return val[:keep_start] + "…" + val[-keep_end:] + + +def systemd_unit_row(unit: str) -> dict[str, str]: + unit = unit.strip() + row = {"unit": unit, "active": "unknown", "enabled": "unknown"} + if not shutil.which("systemctl"): + row["active"] = "n/a" + row["enabled"] = "n/a" + return row + try: + p = subprocess.run( + ["systemctl", "show", unit, "-p", "ActiveState", "-p", "UnitFileState", "--no-pager"], + capture_output=True, + text=True, + timeout=8, + ) + txt = p.stdout or "" + for line in txt.splitlines(): + if line.startswith("ActiveState="): + row["active"] = line.split("=", 1)[-1].strip() or "unknown" + if line.startswith("UnitFileState="): + row["enabled"] = line.split("=", 1)[-1].strip() or "unknown" + except Exception: + row["active"] = "error" + row["enabled"] = "error" + return row + + +def docker_container_row(name: str, display_unit: str | None = None) -> dict[str, str]: + name = name.strip() + disp = (display_unit or "").strip() + row = {"unit": disp if disp else f"{name} (docker)", "active": "inactive", "enabled": "—"} + if not shutil.which("docker"): + row["active"] = "n/a" + return row + try: + p = subprocess.run( + [ + "docker", + "inspect", + "-f", + "{{.State.Running}} {{.HostConfig.RestartPolicy.Name}}", + name, + ], + capture_output=True, + text=True, + timeout=10, + ) + if p.returncode != 0: + row["active"] = "missing" + return row + parts = (p.stdout or "").strip().split(None, 1) + running = parts[0].lower() == "true" + restart = parts[1] if len(parts) > 1 else "" + row["active"] = "active" if running else "inactive" + row["enabled"] = restart if restart else "no" + except Exception: + row["active"] = "error" + return row + + +def collect_system_payload() -> dict: + ui_ver = os.environ.get("KASKAD_UI_VERSION", "v2.2").strip() + bot_token = os.environ.get("BOT_TOKEN", "") + bot_chat = os.environ.get("BOT_CHAT_ID", "") + + return { + "machine_id": _read_machine_id(), + "uptime": _uptime_h(), + "load": _load_avg(), + "mem": _mem_mb(), + "ipaddrs": _ipv4_addrs(), + "yaskad_version": ui_ver, + "bot_token_masked": mask_secret(bot_token), + "bot_chat_masked": mask_secret(bot_chat, 2, 2), + } + + +def collect_services_rows() -> list[dict[str, str]]: + units_csv = os.environ.get( + "SERVICE_UNITS", + "kaskad_bot.service,kaskad-monitor.service,kaskad-web.service", + ) + units = [u.strip() for u in units_csv.split(",") if u.strip()] + docker_web = os.environ.get("DOCKER_WEB_CONTAINER", "").strip() + docker_disp = os.environ.get("DOCKER_WEB_DISPLAY_UNIT", "kaskad-web.service").strip() + + rows: list[dict[str, str]] = [] + for i, u in enumerate(units): + if i == 2 and docker_web: + rows.append(docker_container_row(docker_web, docker_disp or None)) + else: + rows.append(systemd_unit_row(u)) + return rows diff --git a/templates/index.html b/templates/index.html index b36f6af..ce18e3c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,14 +3,19 @@ - Каскад AmneziaWG — памятка + {{ ui_title }} -
+
+
+

{{ ui_title }}

+ +
+ -

VPN · каскад

-

Каскад для AmneziaWG

-

- Краткая памятка: клиент подключается сначала к вашему промежуточному узлу (Endpoint), - а ключ и peer создаются на основном сервере AmneziaWG. Эта страница только объясняет шаги и может собрать текст-памятку; - генерация .conf выполняется в вашей админ-панели или вручную. -

+
+

Система

+
+
- {% if panel_url %} -

Ваша панель: {{ panel_url }}

- {% endif %} +
+

Сервисы

+
+ + + + + +
UNITACTIVEENABLED
+
+
-
- +
+
+

Клиенты (правила NAT)

+ +
+
+ + + + + + + + +
USERPROTOINTARGETOUTЗАМЕТКАГДЕ
+
+
+ +
+ - Что такое каскад + Сырые iptables правила (kaskad)
-

- В конфиге клиента поле Endpoint указывает на IP или DNS узла «перед» VPS - (домашний роутер с пробросом UDP, другой VPS и т.д.). На этом узле нужен проброс порта на ваш основной сервер WireGuard/AWG. - Сервер AmneziaWG добавляет peer и выдаёт готовый .conf. -

+ +

       
-
- - - Через панель Amnezia Admin - -
-

- Если установлена Amnezia Admin WebUI, откройте блок «Новый клиент под каскад»: - укажите Endpoint (ваш публичный IP/DNS узла и при необходимости порт), затем скачайте .conf. -

-

- Репозиторий панели: - github.com/andrey271192/Amnezia_web -

-
-
- -
- - - Собрать памятку (локально в браузере) - -
-

Данные не отправляются на сервер — только текст для копирования.

-
- - - - - - - - - -
- - -
-
-

-      
-
- -
- - - Проброс UDP - -
-

- На промежуточном узле пробросьте UDP с выбранного порта на IP VPS и порт, где слушает ваш инстанс AmneziaWG. - Убедитесь, что фаервол не режет входящий UDP. -

-
-
+
+

Мониторы

+

— нет ни одного —

+
- Сервис: HTTP Basic Auth (realm «kaskad»). Установка одной ссылкой — см. README репозитория kaskad_web_vpn. + Kaskad Web UI · kaskad_web_vpn · HTTP Basic Auth · DNAT в цепочке KASKAD_WEB (nat).
- + + + +