mirror of
https://github.com/andrey271192/kaskad_web_vpn.git
synced 2026-09-20 11:55:35 +00:00
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>
198 lines
6.3 KiB
Python
198 lines
6.3 KiB
Python
"""Хранение правил 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)
|