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:
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)
|
||||
Reference in New Issue
Block a user