mirror of
https://github.com/andrey271192/kaskad_web_vpn.git
synced 2026-09-20 11:55:35 +00:00
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:
10
Dockerfile
10
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
|
||||
|
||||
|
||||
68
README.md
68
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/<id>`, `DELETE /api/clients/<id>`
|
||||
- `GET /api/iptables/raw`, `POST /api/iptables/sync`
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
||||
120
app.py
120
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/<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():
|
||||
|
||||
27
install.sh
27
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,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
197
kaskad_store.py
Normal 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: 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)
|
||||
@@ -1,2 +1,3 @@
|
||||
Flask==3.0.3
|
||||
gunicorn==23.0.0
|
||||
psutil==6.1.0
|
||||
|
||||
242
static/dashboard.js
Normal file
242
static/dashboard.js
Normal 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, "&")
|
||||
.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, "<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();
|
||||
})();
|
||||
@@ -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
191
system_info.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user