Files
kaskad_web_vpn/kaskad_store.py
Андрей Бобырев d3a69e6ec5 docs: drop third-party names; simplify system panel
README turnkey focus; remove BOT fields and yaskad label; neutral iptables comments; default SERVICE_UNITS without bot units.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 21:06:37 +03:00

463 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Хранение правил NAT и синхронизация с iptables.
Режимы (KASKAD_IPTABLES_MODE):
- compat — DNAT в PREROUTING, INPUT/FORWARD с комментарием kaskad:PORT:proto, MASQUERADE.
- chain — отдельная цепочка nat/KASKAD_WEB (альтернатива).
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import re
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"
IPTABLES_MODE = os.environ.get("KASKAD_IPTABLES_MODE", "compat").strip().lower()
_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 detect_out_interface() -> str | None:
env = os.environ.get("KASKAD_OUT_IFACE", "").strip()
if env:
return env
try:
p = subprocess.run(
["ip", "-4", "route", "get", "8.8.8.8"],
capture_output=True,
text=True,
timeout=5,
)
if p.returncode != 0:
return None
m = re.search(r"\bdev\s+(\S+)", p.stdout or "")
return m.group(1) if m else None
except Exception:
return None
def _kaskad_comment(in_port: int, proto: str) -> str:
return f"kaskad:{int(in_port)}:{proto}"
def _iptables_delete_line(table: str | None, full_line: str) -> tuple[int, str]:
"""Строка из `iptables -S`: `-A`/`-I CHAIN ...` → удаление через `-D`."""
parts = full_line.strip().split()
if len(parts) < 3:
return 1, "bad iptables line"
op = parts[0]
if op not in ("-A", "-I"):
return 1, f"unsupported {op}"
parts[0] = "-D"
if op == "-I" and len(parts) > 3 and parts[2].isdigit():
parts.pop(2)
args = parts
if table:
args = ["-t", table] + args
return _run_iptables(args)
def _flush_matching_rules(table: str | None, chain: str, predicate) -> None:
for _ in range(64):
rc, out = _run_iptables(["-t", table, "-S", chain] if table else ["-S", chain])
if rc != 0:
break
prefix = f"-A {chain} "
victim = None
for line in (out or "").splitlines():
line = line.strip()
if not line.startswith(prefix):
continue
rest = line[len(prefix) :]
if predicate(rest):
victim = line
break
if not victim:
break
rc2, err = _iptables_delete_line(table, victim)
if rc2 != 0:
log.debug("iptables delete fail: %s", err)
break
def compat_remove_rule(proto: str, in_port: int) -> None:
"""Удаляет связанные DNAT и правила filter по порту/протоколу."""
cm = _kaskad_comment(in_port, proto)
def pred_nat(rest: str) -> bool:
return (
f"-p {proto}" in rest
and f"--dport {in_port}" in rest
and "DNAT" in rest
and "--to-destination" in rest
)
_flush_matching_rules("nat", "PREROUTING", pred_nat)
def pred_filter(rest: str) -> bool:
return cm in rest
_flush_matching_rules(None, "INPUT", pred_filter)
_flush_matching_rules(None, "FORWARD", pred_filter)
def compat_apply_rule(r: dict[str, Any], iface: str) -> tuple[bool, str]:
proto = str(r["proto"])
in_port = int(r["in_port"])
out_port = int(r["out_port"])
target = str(r["target"])
cm = _kaskad_comment(in_port, proto)
compat_remove_rule(proto, in_port)
rc, err = _run_iptables(
[
"-I",
"INPUT",
"1",
"-p",
proto,
"--dport",
str(in_port),
"-m",
"comment",
"--comment",
cm,
"-j",
"ACCEPT",
]
)
if rc != 0:
return False, f"INPUT: {err}"
rc, err = _run_iptables(
[
"-t",
"nat",
"-A",
"PREROUTING",
"-p",
proto,
"--dport",
str(in_port),
"-j",
"DNAT",
"--to-destination",
f"{target}:{out_port}",
]
)
if rc != 0:
return False, f"PREROUTING DNAT: {err}"
rc, err = _run_iptables(
[
"-I",
"FORWARD",
"1",
"-p",
proto,
"-d",
target,
"--dport",
str(out_port),
"-m",
"state",
"--state",
"NEW,ESTABLISHED,RELATED",
"-m",
"comment",
"--comment",
cm,
"-j",
"ACCEPT",
]
)
if rc != 0:
return False, f"FORWARD→dst: {err}"
rc, err = _run_iptables(
[
"-I",
"FORWARD",
"1",
"-p",
proto,
"-s",
target,
"--sport",
str(out_port),
"-m",
"state",
"--state",
"ESTABLISHED,RELATED",
"-m",
"comment",
"--comment",
cm,
"-j",
"ACCEPT",
]
)
if rc != 0:
return False, f"FORWARD←src: {err}"
return True, ""
def _ensure_masquerade(iface: str) -> tuple[bool, str]:
rc, _ = _run_iptables(["-t", "nat", "-C", "POSTROUTING", "-o", iface, "-j", "MASQUERADE"])
if rc == 0:
return True, ""
rc2, err = _run_iptables(["-t", "nat", "-A", "POSTROUTING", "-o", iface, "-j", "MASQUERADE"])
if rc2 != 0:
return False, err
return True, ""
def _save_persistent() -> None:
if shutil.which("netfilter-persistent"):
subprocess.run(
["netfilter-persistent", "save"],
capture_output=True,
timeout=60,
)
elif shutil.which("iptables-save") and Path("/etc/init.d/iptables").is_file():
subprocess.run(["service", "iptables", "save"], capture_output=True, timeout=60)
def sync_iptables_compat(rules: list[dict[str, Any]]) -> tuple[bool, str]:
iface = detect_out_interface()
if not iface:
return False, "не удалось определить исходящий интерфейс (задайте KASKAD_OUT_IFACE)"
old_disk = load_rules()
for r in old_disk:
compat_remove_rule(str(r["proto"]), int(r["in_port"]))
for r in rules:
ok, msg = compat_apply_rule(r, iface)
if not ok:
return False, msg
ok_m, msg_m = _ensure_masquerade(iface)
if not ok_m:
return False, f"MASQUERADE: {msg_m}"
try:
_save_persistent()
except Exception as e:
log.debug("persistent save: %s", e)
return True, ""
def sync_iptables_chain(rules: list[dict[str, Any]]) -> tuple[bool, str]:
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 sync_iptables(rules: list[dict[str, Any]]) -> tuple[bool, str]:
if IPTABLES_MODE == "chain":
return sync_iptables_chain(rules)
return sync_iptables_compat(rules)
def iptables_chain_dump() -> str:
exe = _iptables_bin()
if not exe:
return "(iptables недоступен)"
try:
if IPTABLES_MODE == "chain":
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} пуста)"
chunks: list[str] = []
for label, args in [
("# nat PREROUTING (DNAT)", ["-t", "nat", "-S", "PREROUTING"]),
("# filter INPUT (kaskad)", ["-S", "INPUT"]),
("# filter FORWARD (kaskad)", ["-S", "FORWARD"]),
("# nat POSTROUTING (MASQUERADE)", ["-t", "nat", "-S", "POSTROUTING"]),
]:
p = subprocess.run([exe, *args], capture_output=True, text=True, timeout=15)
body = (p.stdout or "").strip()
lines = [
ln
for ln in body.splitlines()
if "kaskad:" in ln or ("DNAT" in ln and "PREROUTING" in args[2])
or ("MASQUERADE" in ln and "POSTROUTING" in args[2])
]
chunks.append(label)
chunks.append("\n".join(lines) if lines else "(нет совпадений)")
return "\n".join(chunks)
except Exception as e:
return f"(ошибка: {e})"
def startup_resync() -> None:
try:
rules = load_rules()
ok, msg = sync_iptables(rules)
if ok:
log.info("iptables синхронизированы (%s), правил: %s", IPTABLES_MODE, len(rules))
else:
log.warning("iptables не синхронизированы: %s", msg)
except Exception as e:
log.warning("startup_resync: %s", e)