mirror of
https://github.com/andrey271192/kaskad_web_vpn.git
synced 2026-09-20 11:55:35 +00:00
compat DNAT/INPUT/FORWARD/MASQUERADE; optional chain mode; auth_store hash; static absolute URLs; install skips env password by default; README. Co-authored-by: Cursor <cursoragent@cursor.com>
463 lines
14 KiB
Python
463 lines
14 KiB
Python
"""Хранение правил NAT и синхронизация с iptables.
|
||
|
||
Режимы (KASKAD_IPTABLES_MODE):
|
||
- compat — как Kaskad PRO: 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: 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 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 + INPUT/FORWARD как в Kaskad PRO."""
|
||
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)
|