Some checks failed
Windows project source guards / test (push) Failing after 31s
Движок разложен на части с одной задачей каждая: - endpoint: вид сервера (TCP / QUIC / WireGuard), хосты, порты, DNS; - strategies: каталоги winws2 и правило для выбранного сервера; - presets: файлы пресетов; - command: сборка командной строки winws2 (чистая функция); - manager: процесс winws2 с одним входом apply(план) и ревизиями — готовность только по started процесса нужной ревизии, таймаут 5 с. Склейка с подключением (~450 строк в контроллере, два наслоенных механизма «UDP-защиты» и «профиля сервера», кэш и прогрев DNS) заменена шлюзом application/server_bypass.py. Сохранены: DNS только в воркере, остановка туннеля перед сменой правила (кроме восстановления Hysteria), забор перед стартом ядра, fail-closed при потере winws2 (теперь и по таймауту перезапуска), маскировка IP сервера в логах до старта ядра; адреса серверов пула изучаются в фоне после подключения. Поведение: - у QUIC и WireGuard теперь свои стратегии; ключ zapret_target совместим, старые сборки после отката читают udp_* как раньше; - в каталогах вернулись 82 составные стратегии с ветками --payload (валидатор принимал только --lua-desync и молча их выбрасывал); - смена пресета под защищённой сессией идёт через переход: туннель останавливается, winws2 перезапускается, VPN подключается снова; - пропавший файл выбранного пресета — откат к Default, а не отказ; - правка работающего пресета перезапускает его; переименование больше не оставляет копию, занятое имя отклоняется; - настройки Zapret не откатываются при сохранении страницы «Настройки». Интерфейс: карточка состояния (работает/нет, пресет, одна кнопка, автозапуск), выбранный VPN-сервер с живым правилом, три строки видов серверов с подстраницей стратегии, пресеты — отдельной подстраницей. Объяснение — в окне «Как это б#&^ь работает?» с иллюстрацией.
336 lines
26 KiB
Python
336 lines
26 KiB
Python
"""«Как это б#&^ь работает?» — объяснение раздела «Маршрутизации» для новичков.
|
||
|
||
На основном экране раздела остаются только заголовок и сами данные; длинные
|
||
пояснения и живая иллюстрация раздела показываются по ссылке в стоковом
|
||
окне qfluentwidgets поверх приложения (``MessageBoxBase`` с затемнением).
|
||
Окно создаётся при открытии и удаляется через ``dispose_later`` после
|
||
закрытия: в нём есть ScrollArea со SmoothScrollDelegate (см. qt_lifecycle).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Callable
|
||
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||
from qfluentwidgets import (
|
||
BodyLabel,
|
||
FluentIcon as FIF,
|
||
HyperlinkButton,
|
||
MessageBoxBase,
|
||
ScrollArea,
|
||
StrongBodyLabel,
|
||
SubtitleLabel,
|
||
)
|
||
|
||
from ..qt_lifecycle import dispose_later
|
||
from .art import (
|
||
ArtCanvas,
|
||
DnsArt,
|
||
JsonArt,
|
||
OutboundHubArt,
|
||
RouteMapArt,
|
||
RuleScanArt,
|
||
RuleSetArt,
|
||
TunnelArt,
|
||
ZapretArt,
|
||
)
|
||
|
||
LINK_TEXT = "Как это б#&^ь работает?"
|
||
#: «&» у кнопок Qt — метка горячей клавиши; «&&» рисует сам символ.
|
||
LINK_LABEL = LINK_TEXT.replace("&", "&&")
|
||
|
||
#: Абзац-строка — обычный текст; ``("h", …)`` — подзаголовок.
|
||
Paragraph = str | tuple[str, str]
|
||
|
||
GUIDES: dict[str, tuple[str, tuple[Paragraph, ...]]] = {
|
||
"overview": (
|
||
"Маршрутизация: что это вообще такое?",
|
||
(
|
||
"Когда VPN включён, трафик компьютера проходит через ядро sing-box. Маршрутизация — это "
|
||
"инструкция для ядра, что делать с каждым соединением: пустить через выбранный сервер "
|
||
"(прокси), отправить напрямую, как будто VPN выключен, или заблокировать.",
|
||
"Зачем это нужно: российские сайты и банки часто не открываются через заграничный сервер "
|
||
"или работают медленнее, а заблокированные сайты, наоборот, открываются только через него. "
|
||
"Правила разводят трафик так, чтобы работало всё сразу.",
|
||
"Картинка сверху — ваш конфиг: пакеты от устройства разбегаются по трём дорожкам, и чем "
|
||
"больше правил ведёт в прокси, напрямую или в блок, тем гуще соответствующий поток.",
|
||
("h", "Что где лежит"),
|
||
"• Правила — главное: какой сайт или программа куда идёт.\n"
|
||
"• Наборы правил — большие готовые списки сайтов и адресов.\n"
|
||
"• DNS — как узнаются адреса сайтов.\n"
|
||
"• Исходящие — куда вообще можно отправить трафик.\n"
|
||
"• Система — как трафик попадает в ядро (TUN) и служебные настройки.\n"
|
||
"• JSON — всё то же самое одним текстом, для опытных.",
|
||
("h", "Конфиг и шаблон"),
|
||
"Конфиг — ваш рабочий файл, шаблон — стоковый вариант от разработчиков. При обновлении "
|
||
"приложения разделы, которые вы не трогали, сами подтягиваются из нового шаблона, а "
|
||
"изменённые вами остаются как есть.",
|
||
"Если что-то сломалось, «Сбросить раздел к стоку…» или «Сбросить всё к стоку» вернут "
|
||
"заводское состояние. Сброс всего конфига записывается на диск сразу.",
|
||
("h", "Кнопки сверху"),
|
||
"• Проверить — ядро ищет ошибки в конфиге, ничего не сохраняя.\n"
|
||
"• Сохранить — записать правки на диск.\n"
|
||
"• Применить — сохранить и, если VPN подключён, сразу переподключиться с новыми правилами.\n"
|
||
"• Отменить изменения — вернуть последнее сохранённое состояние.\n"
|
||
"Пульсирующая точка слева значит, что есть несохранённые правки.",
|
||
"Совет новичку: если всё открывается — ничего здесь не трогайте.",
|
||
),
|
||
),
|
||
"rules": (
|
||
"Правила: как это работает?",
|
||
(
|
||
"Правило — это «если …, то …». Слева условие: домен, IP-адрес, программа, порт или набор "
|
||
"правил. Справа действие: куда отправить такое соединение.",
|
||
"Порядок важен. Ядро проверяет правила сверху вниз и останавливается на первом "
|
||
"подошедшем — анимация сверху показывает именно это: «зонд» бежит по списку и замирает на "
|
||
"первом совпадении. Поэтому точечные правила ставьте выше общих.",
|
||
("h", "Куда можно отправить"),
|
||
"• Прокси (выбранный сервер) — через VPN.\n"
|
||
"• Напрямую — мимо VPN, как без него.\n"
|
||
"• Блокировать — соединение сбрасывается.\n"
|
||
"• Определить протокол (sniff) и Перехватить DNS — служебные правила в начале списка: "
|
||
"первое узнаёт, на какой сайт идёт соединение (без него правила по доменам не сработают), "
|
||
"второе отдаёт DNS-запросы самому ядру. Их лучше не трогать.",
|
||
("h", "Условия"),
|
||
"Если в правиле несколько доменов — подходит любой из них. Если указаны разные виды "
|
||
"условий, например домен и порт, — должны совпасть оба. «Логическое правило (И / ИЛИ)» "
|
||
"собирает несколько правил в одно.",
|
||
"«Если ничего не подошло» внизу страницы решает, куда уходит весь остальной трафик.",
|
||
("h", "Как добавить правило"),
|
||
"«Добавить правило» → «Сайты, IP, программы (просто)». Выберите, куда отправить — через VPN, "
|
||
"напрямую или заблокировать, — и перечислите сайты, IP-адреса или программы, по одному в "
|
||
"строке. Правило сработает, если совпадёт что-нибудь из списков, и само встанет наверх — выше "
|
||
"стоковых, но после служебных и защитных (блок DoT и сервисов проверки IP). «Добавить и "
|
||
"применить» сразу включит его, если VPN подключён; просто «Добавить» — после «Применить».",
|
||
"Записи как в v2rayN: «2ip.ru» — сайт со всеми поддоменами, «full:» — точно этот адрес, "
|
||
"«keyword:» или слово без точки — по слову, «geosite:» и «geoip:» — готовые наборы, "
|
||
"«geoip:private» — локальные адреса. Программа — имя файла (telegram.exe) или полный путь, "
|
||
"регистр не важен.",
|
||
("h", "Почему сайт идёт не туда"),
|
||
"Впишите сайт в строку «Проверить сайт» над списком: приложение пройдёт правила так же, как "
|
||
"ядро, и покажет, какое сработает и куда уйдёт трафик. Сайты часто подгружают данные с "
|
||
"других адресов — например, свой IP сервис может узнавать через другой домен. Проверьте и "
|
||
"его и добавьте в то же правило.",
|
||
("h", "Как править"),
|
||
"Нажмите на строку, чтобы открыть правило со всеми параметрами ядра. Стрелки справа двигают "
|
||
"его выше или ниже, рядом — копия и удаление.",
|
||
),
|
||
),
|
||
"rule_sets": (
|
||
"Наборы правил: как это работает?",
|
||
(
|
||
"Набор правил — большой готовый список сайтов или IP-адресов: например, все российские "
|
||
"сайты или всё, что заблокировано в РФ. Вместо тысяч доменов вручную правило ссылается на "
|
||
"набор одним словом.",
|
||
"Обычно geosite-… — списки доменов, geoip-… — списки IP-адресов.",
|
||
("h", "Виды наборов"),
|
||
"• Локальный файл — .srs или .json на диске; относительные пути считаются от папки core.\n"
|
||
"• По URL — скачивается из интернета и обновляется сам.\n"
|
||
"• Встроенный — правила записаны прямо в конфиге.",
|
||
"Картинка сверху показывает, сколько наборов каждого вида в вашем конфиге.",
|
||
("h", "Важно"),
|
||
"Сам по себе набор ничего не делает: он работает, только когда на него ссылается правило "
|
||
"(в «Правилах» — условие «наборы»). Под каждым набором написано, используется ли он.",
|
||
"Если набор нужен какому-то правилу, приложение предупредит перед удалением: без него ядро "
|
||
"не запустится.",
|
||
),
|
||
),
|
||
"dns": (
|
||
"DNS: как это работает?",
|
||
(
|
||
"DNS — телефонная книга интернета: превращает имя сайта (youtube.com) в IP-адрес. Прежде "
|
||
"чем открыть сайт, компьютер спрашивает адрес у DNS-сервера.",
|
||
"Почему это важно: провайдер может подменять ответы для заблокированных сайтов, а по "
|
||
"запросам видно, куда вы ходите. Поэтому на DNS-запросы отвечает само ядро и само решает, "
|
||
"у какого сервера спросить.",
|
||
("h", "Серверы"),
|
||
"Список тех, у кого можно спросить: обычный UDP, зашифрованные DoH (https) и DoT (tls), "
|
||
"системный и другие. Порядок не важен — сервер выбирают правила.",
|
||
"bootstrap-dns нужен самому приложению (через него узнаются адреса других серверов), "
|
||
"поэтому он помечен и удалять его не стоит.",
|
||
("h", "Правила DNS"),
|
||
"Работают как обычные правила: сверху вниз, первое совпадение выбирает сервер. Например, "
|
||
"российские домены — к российскому DNS, всё остальное — через защищённый. Если ничего не "
|
||
"подошло, запрос уходит на сервер из «final».",
|
||
"Обновления приложения больше не перезаписывают ваш DNS. Вернуть стоковый вариант можно "
|
||
"на странице «Обзор».",
|
||
),
|
||
),
|
||
"outbounds": (
|
||
"Исходящие: как это работает?",
|
||
(
|
||
"Исходящие (outbounds) — выходы, через которые трафик покидает ядро. Правила лишь "
|
||
"выбирают один из выходов по его имени-тегу.",
|
||
"Картинка сверху — ваши выходы вокруг ядра.",
|
||
("h", "Главные выходы"),
|
||
"• proxy — особый выход: при подключении приложение подставляет сюда сервер, выбранный на "
|
||
"странице «Серверы». Поэтому здесь его можно только посмотреть.\n"
|
||
"• direct — напрямую, мимо VPN. Он нужен самому приложению.\n"
|
||
"• block — «чёрная дыра»: всё, что туда отправлено, пропадает.",
|
||
("h", "Для опытных"),
|
||
"Можно добавить свой сервер (vless, trojan, shadowsocks и другие) и отправлять в него часть "
|
||
"трафика правилом. selector и urltest — группы: выбирают один из нескольких серверов "
|
||
"вручную или автоматически по пингу.",
|
||
"Endpoints — WireGuard, WARP и похожие. Приложение допускает один WireGuard endpoint без "
|
||
"outbound proxy (AmneziaWG).",
|
||
"Если на выход ссылаются правила, приложение предупредит перед удалением: иначе ядро не "
|
||
"запустится.",
|
||
),
|
||
),
|
||
"system": (
|
||
"Система: как это работает?",
|
||
(
|
||
"Служебная часть конфига. Новичку здесь обычно менять нечего.",
|
||
("h", "Входящие"),
|
||
"Так трафик попадает в ядро. TUN — виртуальная сетевая карта: Windows отправляет в неё "
|
||
"трафик всех программ, это и показывает картинка сверху. Для режима TUN такой вход "
|
||
"обязателен. Входящие socks/http/mixed — для режима системного прокси, их порты "
|
||
"приложение при запуске подставляет само.",
|
||
("h", "Остальное"),
|
||
"• Маршрутизация: общие параметры — редкие настройки route.*, кроме правил и наборов.\n"
|
||
"• Журнал ядра — сколько подробностей писать в лог. Приложение читает журнал из вывода "
|
||
"ядра: если направить его в файл или отключить, ошибки пропадут из «Логов».\n"
|
||
"• Experimental — cache_file хранит скачанные наборы и DNS между запусками. Через Clash API "
|
||
"приложение узнаёт скорость и переключает серверы; его адрес назначается само, а секрет "
|
||
"не поддерживается — иначе перестанут работать метрики и переключение.\n"
|
||
"• Прочие секции — всё остальное, что знает ядро.",
|
||
),
|
||
),
|
||
"json": (
|
||
"JSON: как это работает?",
|
||
(
|
||
"Это тот же самый конфиг целиком, текстом в формате JSON — ровно то, что читает ядро "
|
||
"sing-box. Остальные разделы — удобные формы поверх этого текста.",
|
||
"Правки здесь и в разделах — одно и то же. Разделы обновятся, когда вы уйдёте с этой "
|
||
"страницы.",
|
||
"Если ошибиться в синтаксисе (лишняя запятая, незакрытая скобка), разделы станут "
|
||
"недоступны, пока текст не будет исправлен. Кнопка «Проверить» покажет, что не так.",
|
||
"Если в outbounds есть тег proxy, при запуске туда подставляется выбранный сервер.",
|
||
"Сюда удобно вставить готовый кусок конфига из инструкции или скопировать конфиг целиком, "
|
||
"чтобы поделиться им.",
|
||
),
|
||
),
|
||
"zapret": (
|
||
"Zapret: как пробить блокировку",
|
||
(
|
||
"Провайдер следит за трафиком системой DPI: по первым пакетам соединения она узнаёт сайт "
|
||
"или VPN-протокол и обрывает его. Zapret (программа winws2) работает на вашем компьютере, "
|
||
"до провайдера, и слегка портит эти первые пакеты: режет их на части, пускает перед ними "
|
||
"обманку, меняет служебные поля. DPI сбивается и пропускает соединение, а сервер собирает "
|
||
"данные как обычно.",
|
||
("h", "Две задачи"),
|
||
"• Пресет — обход для сайтов и программ (YouTube, Discord и т. д.). Это готовый набор "
|
||
"правил winws2. Он запускается кнопкой «Запустить» и работает независимо от VPN.\n"
|
||
"• Обход до VPN-сервера — отдельное правило только для адреса выбранного сервера. Оно нужно, "
|
||
"когда провайдер режет само подключение к VPN. Правило добавляется поверх пресета.",
|
||
("h", "Виды серверов"),
|
||
"У TCP-серверов, QUIC-серверов и WireGuard — своя стратегия, потому что обманывать DPI для "
|
||
"них нужно по-разному.",
|
||
"• TCP (VLESS, Trojan, Shadowsocks…): «выключено» значит «по пресету» — к серверу применяется "
|
||
"то же, что и ко всем сайтам.\n"
|
||
"• QUIC (Hysteria2, TUIC) и WireGuard: «выключено» значит «не трогать» — Zapret нарочно "
|
||
"пропускает пакеты сервера мимо пресета, потому что его обманки ломают такие туннели.",
|
||
("h", "Что происходит при подключении"),
|
||
"Если для вида сервера обход включён, приложение само узнаёт свежий IP сервера, запускает "
|
||
"Zapret (с выбранным пресетом, а если он не выбран — с «Default») и пускает VPN только после "
|
||
"того, как Zapret заработал с нужным правилом. Если Zapret остановится посреди такой сессии, "
|
||
"VPN тоже остановится — чтобы трафик не пошёл без обхода.",
|
||
"Смена пресета или стратегии во время такой сессии коротко переподключает VPN: туннель "
|
||
"останавливается, Zapret перезапускается с новыми настройками, и VPN подключается снова.",
|
||
("h", "Как выбрать стратегию"),
|
||
"Стратегия — это рецепт порчи пакетов. Метка подсказывает, насколько она проверена: начните "
|
||
"с «Рекомендуется» и «Стабильная». Не помогло — пробуйте следующую: у разных провайдеров "
|
||
"разные DPI. «Своя стратегия» — ваши строки --lua-desync=… из инструкций к zapret.",
|
||
("h", "Права администратора"),
|
||
"winws2 перехватывает пакеты через драйвер WinDivert, поэтому Zapret работает, только "
|
||
"когда приложение запущено от имени администратора.",
|
||
),
|
||
),
|
||
}
|
||
|
||
ART_CLASSES: dict[str, type[ArtCanvas]] = {
|
||
"overview": RouteMapArt,
|
||
"rules": RuleScanArt,
|
||
"rule_sets": RuleSetArt,
|
||
"dns": DnsArt,
|
||
"outbounds": OutboundHubArt,
|
||
"system": TunnelArt,
|
||
"json": JsonArt,
|
||
"zapret": ZapretArt,
|
||
}
|
||
|
||
|
||
def help_link(parent: QWidget) -> HyperlinkButton:
|
||
"""Ссылка без рамки: акцентный текст с иконкой вопроса."""
|
||
link = HyperlinkButton(parent)
|
||
link.setText(LINK_LABEL)
|
||
link.setIcon(FIF.HELP)
|
||
link.setToolTip(LINK_TEXT)
|
||
link.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||
return link
|
||
|
||
|
||
class GuideDialog(MessageBoxBase):
|
||
"""Стоковое окно поверх приложения: иллюстрация раздела и текст для новичка."""
|
||
|
||
#: Ширина окна и отступ от краёв главного окна.
|
||
WIDTH = 680
|
||
MARGIN = 48
|
||
|
||
def __init__(self, key: str, parent: QWidget, feed: Callable[[ArtCanvas], None] | None = None):
|
||
super().__init__(parent)
|
||
title, paragraphs = GUIDES[key]
|
||
self.key = key
|
||
self.title_label = SubtitleLabel(title, self.widget)
|
||
self.viewLayout.addWidget(self.title_label)
|
||
self.art = ART_CLASSES[key](self.widget)
|
||
if feed is not None:
|
||
feed(self.art)
|
||
self.viewLayout.addWidget(self.art)
|
||
|
||
self.scroll = ScrollArea(self.widget)
|
||
self.scroll.setWidgetResizable(True)
|
||
self.scroll.enableTransparentBackground()
|
||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
body = QWidget()
|
||
body.setStyleSheet("QWidget { background: transparent; }")
|
||
column = QVBoxLayout(body)
|
||
column.setContentsMargins(0, 0, 12, 0)
|
||
column.setSpacing(10)
|
||
self.labels: list[BodyLabel] = []
|
||
for paragraph in paragraphs:
|
||
if isinstance(paragraph, tuple):
|
||
label = StrongBodyLabel(paragraph[1], body)
|
||
else:
|
||
label = BodyLabel(paragraph, body)
|
||
label.setWordWrap(True)
|
||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
column.addWidget(label)
|
||
self.labels.append(label)
|
||
column.addStretch(1)
|
||
self.scroll.setWidget(body)
|
||
self.viewLayout.addWidget(self.scroll, 1)
|
||
|
||
self.yesButton.setText("Понятно")
|
||
self.cancelButton.hide()
|
||
self.setClosableOnMaskClicked(True)
|
||
self._fit(parent.window())
|
||
|
||
def _fit(self, window: QWidget) -> None:
|
||
"""Окно не шире и не выше главного: текст прокручивается внутри."""
|
||
width = max(360, min(self.WIDTH, window.width() - 2 * self.MARGIN))
|
||
self.widget.setFixedWidth(width)
|
||
# Заголовок, иллюстрация, кнопка и поля занимают около 330 px.
|
||
self.scroll.setFixedHeight(max(160, min(520, window.height() - 2 * self.MARGIN - 330)))
|
||
|
||
def text(self) -> str:
|
||
return "\n".join(label.text() for label in self.labels)
|
||
|
||
|
||
def open_guide(key: str, parent: QWidget, feed: Callable[[ArtCanvas], None] | None = None) -> GuideDialog:
|
||
"""Показать объяснение без вложенного цикла событий; удалить после закрытия."""
|
||
dialog = GuideDialog(key, parent.window(), feed)
|
||
dialog.finished.connect(lambda _code: dispose_later(dialog))
|
||
dialog.open()
|
||
return dialog
|