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

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)