zapret-kvn/xray_fluent/engines/zapret/command.py
loop-uh 311200bbc0 Zapret: правило на весь набор серверов — смена сервера без остановки VPN
Раньше правило winws2 защищало только выбранный сервер, и переход на
сервер с другим IP по правилам безопасности останавливал VPN целиком,
перезапускал winws2 и поднимал VPN заново (2–3 с в прокси, 12+ с в TUN).
Теперь правило сразу покрывает серверы текущего набора (тот же selector,
что у мгновенного переключения): одним профилем на транспорт и стратегию.

- выбранный сервер, как и прежде, — свежий DNS; остальные — IP из ссылки
  или последние надёжные адреса кэша, без сети;
- перед мгновенным переключением на сервер из набора его адреса заново
  проверяются свежим DNS; не входят в правило — прежний путь с остановкой;
- переподключение к серверу, уже покрытому работающим winws2, его не
  перезапускает.

Замер на win10: смена сервера того же типа 0,2 с вместо 2,7 с (прокси)
и 0,4 с вместо 12,6 с (TUN).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:41:18 +03:00

158 lines
6 KiB
Python

"""winws2 command line: a preset plus, optionally, one rule for the VPN server.
Pure functions, no Qt and no I/O besides the preset read by the caller — so the
exact argument order can be tested directly.
"""
from __future__ import annotations
import ipaddress
import logging
from dataclasses import dataclass
from .blobs import blob_arguments, lua_init_arguments, unresolved_blob_names
from .endpoint import ResolvedEndpoint
from .strategies import Strategy
log = logging.getLogger(__name__)
SERVER_PROFILE_NAME = "--name=ZapretKVN: выбранный сервер"
POOL_PROFILE_NAME = "--name=ZapretKVN: серверы набора"
_CORE_LUA = (
"--lua-init=@lua/zapret-lib.lua",
"--lua-init=@lua/zapret-antidpi.lua",
)
# The first argument with one of these prefixes opens winws2's profile list;
# everything before it is global (lua, blobs, capture filters).
_PROFILE_PREFIXES = (
"--filter-", "--hostlist", "--import", "--in-range", "--ipset",
"--lua-desync", "--name", "--out-range", "--payload", "--skip", "--template",
)
@dataclass(frozen=True, slots=True)
class ServerRule:
"""What winws2 does with the packets of one resolved VPN server."""
target: ResolvedEndpoint
strategy: Strategy
def describe(self) -> str:
endpoint = self.target.endpoint
return (
f"{endpoint.transport.upper()} {endpoint.port_filter}; "
f"{self.strategy.name}; {', '.join(self.target.ips)}"
)
def _loads_lua(global_args: list[str], init: str) -> bool:
filename = init.rsplit("/", 1)[-1]
return any(arg.startswith("--lua-init=") and filename in arg for arg in global_args)
def _merge_capture_filter(global_args: list[str], transport: str, ports: str) -> None:
"""One ``--wf-<transport>-out=`` covering the preset's ports and the server's."""
prefix = f"--wf-{transport}-out="
indexes = [index for index, arg in enumerate(global_args) if arg.startswith(prefix)]
if not indexes:
global_args.append(prefix + ports)
return
values = [global_args[index].removeprefix(prefix) for index in indexes]
if "*" in values:
merged = "*"
else:
tokens = f"{','.join(values)},{ports}".split(",")
merged = ",".join(dict.fromkeys(token.strip() for token in tokens if token.strip()))
global_args[indexes[0]] = prefix + merged
for index in reversed(indexes[1:]):
global_args.pop(index)
def _sorted_ips(values) -> list[str]:
return sorted(
{str(ipaddress.ip_address(value)) for value in values},
key=lambda value: (ipaddress.ip_address(value).version, value),
)
def _merged_ports(endpoints) -> str:
tokens = dict.fromkeys(port for endpoint in endpoints for port in endpoint.ports)
return ",".join(sorted(tokens, key=lambda item: int(item.split("-", 1)[0])))
def build_arguments(
preset_args: list[str],
rule: ServerRule | None,
pool: tuple[ServerRule, ...] = (),
) -> list[str]:
"""Preset arguments with the server rule as the very first profile.
winws2 applies the first matching profile, so the server's exact
IP/transport/port rule placed first wins over any broad preset profile.
The preset list itself is never modified.
``pool`` — the other servers of the running pool. Their rules follow
the selected server's profile, one profile per transport and strategy
(IPs and ports merged), so switching to any of them needs no winws2
restart and therefore no tunnel stop.
"""
if rule is None or not rule.target.ips:
return list(preset_args)
# Profile groups: (transport, strategy) -> rules. The selected server's
# group goes first and keeps its name; its own exact rule is always in it.
groups: dict[tuple[str, Strategy], list[ServerRule]] = {}
for item in (rule, *pool):
if item is None or not item.target.ips:
continue
key = (item.target.endpoint.transport, item.strategy)
groups.setdefault(key, []).append(item)
first_profile = next(
(
index for index, arg in enumerate(preset_args)
if arg == "--new" or arg.startswith("--new=") or arg.startswith(_PROFILE_PREFIXES)
),
len(preset_args),
)
global_args = preset_args[:first_profile]
profiles = preset_args[first_profile:]
for init in reversed(_CORE_LUA):
if not _loads_lua(global_args, init):
global_args.insert(0, init)
server_profiles: list[str] = []
for index, ((transport, strategy), members) in enumerate(groups.items()):
# Extension lua builds on the core API, so it loads after the last lua-init.
lua_tail = max(
(position for position, arg in enumerate(global_args) if arg.startswith("--lua-init=")),
default=-1,
)
for init in reversed(lua_init_arguments(strategy.args)):
if not _loads_lua(global_args, init):
global_args.insert(lua_tail + 1, init)
for definition in blob_arguments(strategy.blob_dependencies):
name = definition.removeprefix("--blob=").split(":", 1)[0]
if not any(arg.startswith(f"--blob={name}:") for arg in global_args):
global_args.append(definition)
unresolved = unresolved_blob_names(strategy.blob_dependencies)
if unresolved:
log.warning(
"Стратегия %s ссылается на неизвестные блобы: %s",
strategy.strategy_id, ", ".join(unresolved),
)
ports = _merged_ports(member.target.endpoint for member in members)
_merge_capture_filter(global_args, transport, ports)
if server_profiles:
server_profiles.append("--new")
server_profiles.extend([
SERVER_PROFILE_NAME if index == 0 else POOL_PROFILE_NAME,
f"--filter-{transport}={ports}",
"--ipset-ip=" + ",".join(_sorted_ips(ip for member in members for ip in member.target.ips)),
*strategy.args,
])
separator = ["--new"] if profiles else []
return [*global_args, *server_profiles, *separator, *profiles]