zapret-kvn/xray_fluent/singbox_config/catalog.py
loop-uh 4555a5474f
Some checks failed
Windows project source guards / test (push) Failing after 38s
Простое правило: регистр программ, слово без точки, защитные правила, «Применить»
- Программы — process_path_regex без учёта регистра (ядро сравнивает
  process_name точно, а файл может быть Telegram.exe); без расширения
  дописывается .exe; в списке показывается имя.
- Слово без точки (youtube) — domain_keyword, как простая строка в Xray.
- Новое правило встаёт после служебных и защитных стоковых (reject и
  outbound block: DoT 853, сервисы проверки IP), а не над ними.
- «Сохранить» при подключении предупреждает, что ядро работает по старым
  правилам; «Проверить сайт» пишет то же, пока не нажато «Применить».
- Устаревшая проверка в очереди не запускает ядро.

Проверено на win10 с настоящим sing-box.exe: rule-set match по .srs и
sing-box check с новыми шаблонами программ.
2026-09-27 13:14:18 +03:00

408 lines
15 KiB
Python
Raw Permalink 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.

"""Presentation catalog for the structured sing-box editor.
Only wording and emphasis live here: Russian labels, which fields are shown
up front, and one-line summaries for list rows. Which fields exist, their
types and allowed values always come from the core schema, so a field that is
missing from this catalog is still editable under its native key.
"""
from __future__ import annotations
from typing import Any
from .document import as_list
from .simple_rule import process_display
FIELD_LABELS: dict[str, str] = {
# rule matching
"domain": "Домены (точно)",
"domain_suffix": "Домены и поддомены",
"domain_keyword": "Домены по слову",
"domain_regex": "Домены (regex)",
"ip_cidr": "IP-адреса и подсети",
"ip_is_private": "Локальные адреса",
"source_ip_cidr": "IP источника",
"source_ip_is_private": "Источник — локальный адрес",
"port": "Порты",
"port_range": "Диапазоны портов",
"source_port": "Порты источника",
"source_port_range": "Диапазоны портов источника",
"process_name": "Процессы (имя файла)",
"process_path": "Процессы (полный путь)",
"process_path_regex": "Процессы (путь, regex)",
"rule_set": "Наборы правил",
"rule_set_ip_cidr_match_source": "Наборы: IP источника",
"network": "Сеть",
"protocol": "Протокол (sniff)",
"client": "Клиент TLS/QUIC",
"inbound": "Входящие",
"ip_version": "Версия IP",
"auth_user": "Пользователь прокси",
"query_type": "Тип DNS-запроса",
"clash_mode": "Режим Clash",
"invert": "Инвертировать условие",
"mode": "Логика",
"rules": "Правила",
# rule actions
"action": "Действие",
"outbound": "Выход",
"method": "Способ",
"no_drop": "Не отбрасывать при частых срабатываниях",
"sniffer": "Анализаторы",
"timeout": "Тайм-аут",
"server": "Сервер",
"strategy": "Стратегия IP",
"override_address": "Подменить адрес",
"override_port": "Подменить порт",
"tls_fragment": "Фрагментировать TLS",
"tls_record_fragment": "Фрагментировать TLS-записи",
"tls_fragment_fallback_delay": "Задержка отката фрагментации",
"udp_timeout": "Тайм-аут UDP",
"udp_connect": "UDP connect",
"disable_cache": "Без кэша",
"rewrite_ttl": "Переписать TTL",
"client_subnet": "Client subnet (ECS)",
# common object keys
"type": "Тип",
"tag": "Тег",
"final": "Если ничего не подошло",
"detour": "Через выход",
"domain_resolver": "DNS для адреса сервера",
"server_port": "Порт сервера",
"path": "Путь",
"url": "URL",
"format": "Формат",
"update_interval": "Интервал обновления",
"outbounds": "Выходы",
"default": "По умолчанию",
"interval": "Интервал",
"tolerance": "Допуск, мс",
# route options
"auto_detect_interface": "Определять сетевой интерфейс",
"default_interface": "Сетевой интерфейс",
"default_domain_resolver": "DNS для доменов по умолчанию",
"find_process": "Определять процесс-владелец",
"default_mark": "Routing mark",
# dns
"servers": "Серверы",
"reverse_mapping": "Обратное сопоставление",
"disable_expire": "Не устаревать кэш",
"cache_capacity": "Размер кэша",
"independent_cache": "Отдельный кэш",
"optimistic": "Оптимистичный кэш",
# inbound tun
"interface_name": "Имя интерфейса",
"address": "Адреса интерфейса",
"mtu": "MTU",
"auto_route": "Автомаршрутизация",
"strict_route": "Строгая маршрутизация",
"stack": "Сетевой стек",
"route_address": "Только эти адреса",
"route_exclude_address": "Кроме этих адресов",
"route_address_set": "Только наборы",
"route_exclude_address_set": "Кроме наборов",
"include_interface": "Только интерфейсы",
"exclude_interface": "Кроме интерфейсов",
"listen": "Адрес прослушивания",
"listen_port": "Порт прослушивания",
# log / experimental
"level": "Уровень",
"timestamp": "Время в строках",
"disabled": "Отключить журнал",
"output": "Файл журнала",
"cache_file": "Файл кэша",
"clash_api": "Clash API",
"enabled": "Включено",
"store_rdrc": "Кэшировать отказы DNS",
"external_controller": "Адрес контроллера",
"secret": "Секрет",
"default_mode": "Режим по умолчанию",
}
ENUM_LABELS: dict[str, dict[Any, str]] = {
"action": {
"route": "Направить в выход",
"route-options": "Задать параметры (не завершает)",
"direct": "Напрямую с параметрами",
"bypass": "Обойти",
"reject": "Заблокировать",
"hijack-dns": "Перехватить DNS",
"sniff": "Определить протокол (sniff)",
"resolve": "Разрешить домен в IP",
"evaluate": "Вычислить ответ",
"respond": "Ответить",
"predefined": "Готовый ответ",
},
"method": {"default": "Отклонить (RST / ICMP)", "drop": "Молча отбросить", "reply": "Ответить (ICMP echo)"},
"mode": {"and": "Все условия (И)", "or": "Любое условие (ИЛИ)"},
"format": {"binary": "Бинарный (.srs)", "source": "Исходный (.json)"},
"level": {
"trace": "trace",
"debug": "debug",
"info": "info",
"warn": "warn",
"warning": "warning",
"error": "error",
"fatal": "fatal",
"panic": "panic",
},
"strategy": {
"as_is": "Как есть",
"prefer_ipv4": "Предпочитать IPv4",
"prefer_ipv6": "Предпочитать IPv6",
"ipv4_only": "Только IPv4",
"ipv6_only": "Только IPv6",
},
}
TYPE_LABELS: dict[str, str] = {
"default": "Обычное",
"logical": "Логическое (И / ИЛИ)",
"inline": "Встроенный",
"local": "Локальный файл",
"remote": "Загружаемый по URL",
}
OUTBOUND_TAG_LABELS: dict[str, str] = {
"proxy": "Прокси (выбранный сервер)",
"direct": "Напрямую",
"block": "Блокировать",
}
SECTION_LABELS: dict[str, str] = {
"log": "Журнал",
"dns": "DNS",
"ntp": "NTP",
"inbounds": "Входящие",
"outbounds": "Исходящие",
"endpoints": "Endpoints",
"route": "Маршрутизация",
"experimental": "Experimental",
"certificate": "Сертификаты",
"services": "Сервисы",
"providers": "Провайдеры",
"http_clients": "HTTP-клиенты",
}
# Fields shown up front (in this order) even while empty; the rest is added
# through «Добавить параметр». Keys are editor contexts.
PINNED_FIELDS: dict[str, tuple[str, ...]] = {
"rule.action:route": ("outbound",),
"rule.action:reject": ("method",),
"rule.action:resolve": ("server", "strategy"),
"rule.action:sniff": ("sniffer",),
"rule.type:logical": ("mode", "rules"),
"dns_rule.action:route": ("server",),
"dns_rule.type:logical": ("mode", "rules"),
"rule_set.type:local": ("tag", "format", "path"),
"rule_set.type:remote": ("tag", "format", "url", "update_interval"),
"rule_set.type:inline": ("tag", "rules"),
"outbound": ("tag",),
"outbound.type:selector": ("tag", "outbounds", "default"),
"outbound.type:urltest": ("tag", "outbounds", "url", "interval"),
"outbound.type:direct": ("tag", "domain_resolver"),
"dns_server": ("tag", "server", "detour"),
"inbound": ("tag",),
"inbound.type:tun": ("tag", "address", "auto_route", "strict_route", "stack"),
"inbound.type:mixed": ("tag", "listen", "listen_port"),
"route": ("final", "auto_detect_interface", "default_domain_resolver"),
"dns": ("final", "strategy"),
"log": ("level", "timestamp"),
"experimental": ("cache_file",),
"cache_file": ("enabled",),
}
# Matching fields that sing-box only evaluates on other platforms. They stay
# available (the JSON may be shared with Android) but are listed last.
FOREIGN_PLATFORM_FIELDS = frozenset(
{
"user",
"user_id",
"package_name",
"package_name_regex",
"wifi_ssid",
"wifi_bssid",
"network_type",
"network_is_expensive",
"network_is_constrained",
"source_mac_address",
"source_hostname",
"override_android_vpn",
"find_neighbor",
"dhcp_lease_files",
}
)
# Rule fields rendered first in «Добавить условие».
COMMON_MATCH_FIELDS = (
"domain_suffix",
"domain",
"domain_keyword",
"domain_regex",
"rule_set",
"ip_cidr",
"ip_is_private",
"process_name",
"process_path",
"process_path_regex",
"port",
"port_range",
"network",
"protocol",
)
_SUMMARY_FIELDS = (
"domain_suffix",
"domain",
"domain_keyword",
"domain_regex",
"rule_set",
"ip_cidr",
"process_name",
"process_path",
"process_path_regex",
"port",
"port_range",
"protocol",
"network",
"inbound",
"query_type",
"source_ip_cidr",
)
_SUMMARY_SHORT: dict[str, str] = {
"domain_suffix": "домены",
"domain": "домены",
"domain_keyword": "слова",
"domain_regex": "regex",
"rule_set": "наборы",
"ip_cidr": "IP",
"process_name": "процессы",
"process_path": "пути",
"process_path_regex": "пути",
"port": "порт",
"port_range": "порты",
"protocol": "протокол",
"network": "сеть",
"inbound": "вход",
"query_type": "запрос",
"source_ip_cidr": "источник",
}
def field_label(name: str) -> str:
return FIELD_LABELS.get(name, name)
def enum_label(field: str, value: Any) -> str:
if field in ("type",) and isinstance(value, str):
return TYPE_LABELS.get(value, value)
return ENUM_LABELS.get(field, {}).get(value, str(value))
def outbound_label(tag: str) -> str:
label = OUTBOUND_TAG_LABELS.get(tag)
return f"{label} · {tag}" if label else tag
def pinned_fields(context: str, discriminators: dict[str, str] | None = None) -> tuple[str, ...]:
result: list[str] = list(PINNED_FIELDS.get(context, ()))
for key, value in (discriminators or {}).items():
for name in PINNED_FIELDS.get(f"{context}.{key}:{value}", ()):
if name not in result:
result.append(name)
return tuple(result)
def _values_text(values: list, limit: int = 2) -> str:
shown = ", ".join(str(value) for value in values[:limit])
rest = len(values) - limit
return f"{shown} +{rest}" if rest > 0 else shown
def match_summary(rule: dict) -> str:
"""Short description of what a (route or DNS) rule matches."""
if not isinstance(rule, dict):
return "?"
if rule.get("type") == "logical":
nested = [item for item in rule.get("rules") or () if isinstance(item, dict)]
joiner = " И " if rule.get("mode") == "and" else " ИЛИ "
text = joiner.join(f"({match_summary(item)})" for item in nested) or "пусто"
return f"НЕ {text}" if rule.get("invert") else text
parts: list[str] = []
for key in _SUMMARY_FIELDS:
values = as_list(rule.get(key))
if not values:
continue
label = _SUMMARY_SHORT[key]
if key == "process_path_regex":
# Шаблоны «простого правила» показываем как имя программы.
shown = [process_display(str(value)) for value in values]
if all(shown):
values, label = shown, "процессы"
parts.append(f"{label}: {_values_text(values)}")
if rule.get("ip_is_private"):
parts.append("локальные адреса")
if rule.get("source_ip_is_private"):
parts.append("источник локальный")
if not parts:
known = {"type", "invert", "action", "outbound", "server"}
others = [key for key in rule if key not in known and key not in ACTION_KEYS]
parts.append(", ".join(field_label(key) for key in others[:2]) if others else "любой трафик")
text = " · ".join(parts[:3]) + (" · …" if len(parts) > 3 else "")
return f"НЕ ({text})" if rule.get("invert") else text
# Keys that belong to an action rather than to matching. Used only to keep
# action options out of the one-line match summary.
ACTION_KEYS = frozenset(
{
"action",
"outbound",
"method",
"no_drop",
"sniffer",
"timeout",
"server",
"strategy",
"disable_cache",
"disable_optimistic_cache",
"rewrite_ttl",
"client_subnet",
"override_address",
"override_port",
"override_gateway",
"network_strategy",
"fallback_delay",
"udp_disable_domain_unmapping",
"udp_connect",
"udp_timeout",
"tls_fragment",
"tls_fragment_fallback_delay",
"tls_record_fragment",
"tls_spoof",
"tls_spoof_method",
"race",
"speculative",
"remove_client_subnet",
"rcode",
"answer",
"ns",
"extra",
}
)
def action_summary(rule: dict, *, dns: bool = False) -> str:
if not isinstance(rule, dict):
return ""
action = rule.get("action") or "route"
if action == "route":
if dns:
return f"→ {rule.get('server', '?')}"
return f"→ {outbound_label(str(rule.get('outbound', '?')))}"
if action == "reject":
return "✕ " + ("отбросить" if rule.get("method") == "drop" else "заблокировать")
return enum_label("action", action)