feat: Kaskad Web UI v2 dashboard

System/services/NAT CRUD, iptables chain KASKAD_WEB, host-network Docker install, docker CLI in image for status row.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 20:35:34 +03:00
parent 6e5cb44c51
commit 7af4f701e1
10 changed files with 1172 additions and 134 deletions

View File

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

View File

@@ -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/<id>`, `DELETE /api/clients/<id>`
- `GET /api/iptables/raw`, `POST /api/iptables/sync`
## Лицензия
MIT

120
app.py
View File

@@ -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/<cid>")
@_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/<cid>")
@_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():

View File

@@ -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,19 +83,37 @@ 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}" )
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"
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."

197
kaskad_store.py Normal file
View File

@@ -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: 164 символа")
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)

View File

@@ -1,2 +1,3 @@
Flask==3.0.3
gunicorn==23.0.0
psutil==6.1.0

242
static/dashboard.js Normal file
View File

@@ -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 '<span class="' + cls + '">' + escapeHtml(active || "—") + "</span>";
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "<br>")],
["yaskad", data.yaskad_version],
["BOT_TOKEN", data.bot_token_masked],
["BOT_CHAT_ID", data.bot_chat_masked],
];
g.innerHTML = cells
.map(function (kv) {
return (
'<div class="sys-cell"><span class="sys-k">' +
escapeHtml(kv[0]) +
'</span><span class="sys-v">' +
kv[1] +
"</span></div>"
);
})
.join("");
}
function renderServices(rows) {
var tb = document.querySelector("#tbl-services tbody");
tb.innerHTML = (rows || [])
.map(function (r) {
return (
"<tr><td class=\"mono\">" +
escapeHtml(r.unit) +
"</td><td>" +
pill(r.active) +
"</td><td>" +
escapeHtml(r.enabled) +
"</td></tr>"
);
})
.join("");
}
function renderClients(list) {
var tb = document.querySelector("#tbl-clients tbody");
tb.innerHTML = (list || [])
.map(function (c) {
return (
"<tr data-id=\"" +
escapeHtml(c.id) +
"\"><td>" +
escapeHtml(c.user) +
"</td><td class=\"mono\">" +
escapeHtml(c.proto) +
"</td><td class=\"mono\">" +
escapeHtml(String(c.in_port)) +
'</td><td class="arrow-cell">→</td><td class="mono">' +
escapeHtml(c.target) +
"</td><td class=\"mono\">" +
escapeHtml(String(c.out_port)) +
"</td><td>" +
escapeHtml(c.note || "") +
"</td><td>" +
escapeHtml(c.where || "") +
'</td><td class="row-actions"><button type="button" class="icon-btn edit" title="Изменить">✎</button> <button type="button" class="icon-btn del" title="Удалить">✕</button></td></tr>'
);
})
.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();
})();

View File

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

191
system_info.py Normal file
View File

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

View File

@@ -3,14 +3,19 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Каскад AmneziaWG — памятка</title>
<title>{{ ui_title }}</title>
<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&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<div class="shell">
<div class="shell shell-wide">
<div class="top-bar">
<h1 class="product-title">{{ ui_title }}</h1>
<button type="button" class="btn btn-ghost" id="btn-refresh" title="Обновить данные">Обновить</button>
</div>
<header class="site-header">
<div class="support-line">
<span class="support-title">Поддержка автора</span>
@@ -23,122 +28,94 @@
<span class="sep">·</span>
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">Telegram @lot_andrey</a>
</div>
<p class="support-blurb">Звезда репозиторию и донат помогают развивать утилиты. На этой странице нет сторонней рекламы.</p>
{% if panel_url %}
<p class="support-blurb">Панель Amnezia / доп.: <a href="{{ panel_url }}">{{ panel_url }}</a></p>
{% else %}
<p class="support-blurb">Открытый исходный код, без сторонней рекламы на странице.</p>
{% endif %}
</header>
<p class="eyebrow">VPN · каскад</p>
<h1>Каскад для AmneziaWG</h1>
<p class="lead">
Краткая памятка: клиент подключается сначала к вашему <strong>промежуточному узлу</strong> (Endpoint),
а ключ и peer создаются на основном сервере AmneziaWG. Эта страница только объясняет шаги и может собрать текст-памятку;
генерация <span class="inline-code">.conf</span> выполняется в вашей <strong>админ-панели</strong> или вручную.
</p>
<section class="card" id="sec-system">
<h2 class="card-title">Система</h2>
<div class="sys-grid" id="system-grid"></div>
</section>
{% if panel_url %}
<p class="muted">Ваша панель: <a href="{{ panel_url }}">{{ panel_url }}</a></p>
{% endif %}
<section class="card" id="sec-services">
<h2 class="card-title">Сервисы</h2>
<div class="table-wrap">
<table class="data-table" id="tbl-services">
<thead>
<tr><th>UNIT</th><th>ACTIVE</th><th>ENABLED</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<details class="panel-fold" open>
<summary>
<section class="card" id="sec-clients">
<div class="card-head">
<h2 class="card-title">Клиенты (правила NAT)</h2>
<button type="button" class="btn" id="btn-add-client">+ Добавить клиента</button>
</div>
<div class="table-wrap table-scroll">
<table class="data-table" id="tbl-clients">
<thead>
<tr>
<th>USER</th><th>PROTO</th><th>IN</th><th></th><th>TARGET</th><th>OUT</th>
<th>ЗАМЕТКА</th><th>ГДЕ</th><th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<details class="panel-fold card-inner" id="fold-iptables">
<summary class="fold-sum">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Что такое каскад</span>
<span class="fold-h">Сырые iptables правила (kaskad)</span>
</summary>
<div class="fold-body">
<p class="muted">
В конфиге клиента поле <span class="inline-code">Endpoint</span> указывает на IP или DNS узла «перед» VPS
(домашний роутер с пробросом UDP, другой VPS и т.д.). На этом узле нужен проброс порта на ваш основной сервер WireGuard/AWG.
Сервер AmneziaWG добавляет peer и выдаёт готовый <span class="inline-code">.conf</span>.
</p>
<button type="button" class="btn btn-sm" id="btn-load-iptables">Показать</button>
<pre class="mono-block" id="iptables-out"></pre>
</div>
</details>
<details class="panel-fold" open>
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Через панель Amnezia Admin</span>
</summary>
<div class="fold-body">
<p class="muted">
Если установлена <strong>Amnezia Admin WebUI</strong>, откройте блок <strong>«Новый клиент под каскад»</strong>:
укажите Endpoint (ваш публичный IP/DNS узла и при необходимости порт), затем скачайте <span class="inline-code">.conf</span>.
</p>
<p class="muted">
Репозиторий панели:
<a href="https://github.com/andrey271192/Amnezia_web" target="_blank" rel="noopener noreferrer">github.com/andrey271192/Amnezia_web</a>
</p>
</div>
</details>
<details class="panel-fold">
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Собрать памятку (локально в браузере)</span>
</summary>
<div class="fold-body">
<p class="muted">Данные не отправляются на сервер — только текст для копирования.</p>
<div class="grid-form">
<label for="ep-host">Endpoint (хост для клиента)</label>
<input id="ep-host" type="text" placeholder="203.0.113.50 или ddns.example.com" autocomplete="off">
<label for="ep-port">Порт Endpoint (если пусто — как у сервера)</label>
<input id="ep-port" type="number" min="1" max="65535" placeholder="51820" autocomplete="off">
<label for="memo-name">Имя клиента (произвольно)</label>
<input id="memo-name" type="text" placeholder="Телефон через узел X" autocomplete="off">
<div class="btn-row">
<button type="button" class="btn" id="btn-build">Собрать текст</button>
<button type="button" class="btn" id="btn-copy">Копировать</button>
</div>
</div>
<pre class="out-box" id="out-memo" aria-live="polite"></pre>
</div>
</details>
<details class="panel-fold">
<summary>
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-h">Проброс UDP</span>
</summary>
<div class="fold-body">
<p class="muted">
На промежуточном узле пробросьте UDP с выбранного порта на IP VPS и порт, где слушает ваш инстанс AmneziaWG.
Убедитесь, что фаервол не режет входящий UDP.
</p>
</div>
</details>
<section class="card">
<h2 class="card-title">Мониторы</h2>
<p class="muted pad-v">— нет ни одного —</p>
</section>
<footer class="site-footer">
Сервис: HTTP Basic Auth (realm «kaskad»). Установка одной ссылкой — см. README репозитория kaskad_web_vpn.
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).
</footer>
</div>
<script>
(function () {
function buildText() {
var host = (document.getElementById("ep-host").value || "").trim();
var port = (document.getElementById("ep-port").value || "").trim();
var name = (document.getElementById("memo-name").value || "").trim() || "клиент";
var epLine = host ? (host + (port ? ":" + port : "")) : "(укажите Endpoint)";
return (
"Памятка по каскаду\n" +
"— Имя: " + name + "\n" +
"— Endpoint для клиента: " + epLine + "\n" +
"— На промежуточном узле: проброс UDP на VPS и порт WG/AWG.\n" +
"— На основном сервере: создать peer (панель Amnezia Admin → «Новый клиент под каскад») и выдать .conf.\n"
);
}
document.getElementById("btn-build").addEventListener("click", function () {
document.getElementById("out-memo").textContent = buildText();
});
document.getElementById("btn-copy").addEventListener("click", async function () {
var t = document.getElementById("out-memo").textContent || buildText();
try {
await navigator.clipboard.writeText(t);
} catch (e) {
alert("Не удалось скопировать — выделите текст вручную.");
}
});
})();
</script>
<div class="modal-overlay" id="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h3 id="modal-title">Клиент</h3>
<form id="form-client" class="form-grid">
<input type="hidden" name="id" id="f-id">
<label>USER <input name="user" id="f-user" required maxlength="64" autocomplete="off"></label>
<label>PROTO
<select name="proto" id="f-proto">
<option value="udp">udp</option>
<option value="tcp">tcp</option>
</select>
</label>
<label>IN (порт) <input name="in_port" id="f-in" type="number" required min="1" max="65535"></label>
<label>TARGET (IPv4) <input name="target" id="f-target" required autocomplete="off" placeholder="10.0.0.5"></label>
<label>OUT (порт) <input name="out_port" id="f-out" type="number" required min="1" max="65535"></label>
<label class="span-2">ЗАМЕТКА <input name="note" id="f-note" maxlength="256" autocomplete="off"></label>
<label class="span-2">ГДЕ <input name="where" id="f-where" maxlength="256" autocomplete="off" placeholder="Netherlands · узел …"></label>
<div class="modal-actions span-2">
<button type="submit" class="btn">Сохранить</button>
<button type="button" class="btn btn-ghost" id="modal-cancel">Отмена</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='dashboard.js') }}" defer></script>
</body>
</html>