zapret-kvn/xray_fluent/singbox_config/document.py
loop-uh 308f6da16a
Some checks failed
Windows project source guards / test (push) Failing after 38s
Маршрутизация: структурный редактор sing-box по схеме ядра
Раздел «Маршрутизация» с подпунктами (Обзор, Правила, Наборы правил, DNS,
Исходящие, Система, JSON) редактирует тот же активный native JSON.
Формы строятся из JSON Schema закреплённого ядра (sing-box schema),
своей модели маршрутизации нет; неизвестные ключи и форма Listable
сохраняются. «Проверить» запускает sing-box check в фоне.

DNS больше не перезаписывается принудительно: при обновлении шаблонов
нетронутые верхние секции следуют за стоком, изменённые остаются.
Сброс к стоку — вручную, целиком или по разделу.
2026-09-26 20:30:17 +03:00

184 lines
6.7 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.

"""Native sing-box JSON document edited by the structured GUI.
The document is the parsed active config itself — a plain ``dict`` in file
order. Editors change it in place, so keys the GUI does not know about stay
untouched, and serialisation writes the same native JSON back. There is no
intermediate model and no routing policy of the application's own.
"""
from __future__ import annotations
import json
from typing import Any
from .schema import Shape
# Top-level array sections whose items carry a ``tag``.
TAGGED_SECTIONS = {
"outbound": ("outbounds", "endpoints"),
"inbound": ("inbounds",),
"dns_server": (("dns", "servers"),),
"rule_set": (("route", "rule_set"),),
"http_client": ("http_clients",),
"network_namespace": ("network_namespaces",),
"certificate_provider": ("certificate_providers",),
}
PROXY_TAG = "proxy"
DIRECT_TAG = "direct"
BOOTSTRAP_DNS_TAG = "bootstrap-dns"
#: Tags the launch planner references although the source JSON may not:
#: ``sing-box check`` of the edited document cannot notice their absence.
LAUNCH_REQUIRED_TAGS = {
"outbounds": {
DIRECT_TAG: (
"Нужен приложению: при запуске сюда направляются служебные маршруты "
"(адрес выбранного сервера, Hysteria, тест скорости, пул серверов)."
),
},
"dns.servers": {
BOOTSTRAP_DNS_TAG: (
"Нужен приложению: этим DNS при запуске разрешается доменное имя "
"выбранного сервера."
),
},
}
APP_PROXY_INBOUND_TYPES = frozenset({"socks", "http", "mixed"})
RESERVED_TAG_PREFIXES = ("__app_", "node_")
def parse_document(text: str) -> tuple[dict | None, str]:
"""Return ``(document, "")`` or ``(None, error)`` for editor text."""
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
return None, f"Строка {exc.lineno}, столбец {exc.colno}: {exc.msg}"
if not isinstance(payload, dict):
return None, "Корень конфига sing-box должен быть JSON-объектом."
return payload, ""
def dump_document(document: dict) -> str:
return json.dumps(document, ensure_ascii=False, indent=2) + "\n"
def section_items(document: dict, section: str | tuple[str, ...]) -> list:
"""Return the live list for ``outbounds`` or ``("dns", "servers")``."""
path = (section,) if isinstance(section, str) else section
node: Any = document
for key in path:
if not isinstance(node, dict):
return []
node = node.get(key)
return node if isinstance(node, list) else []
def ensure_section_items(document: dict, section: str | tuple[str, ...]) -> list:
"""Like :func:`section_items`, creating missing containers on the way."""
path = (section,) if isinstance(section, str) else section
node = document
for key in path[:-1]:
child = node.get(key)
if not isinstance(child, dict):
child = {}
node[key] = child
node = child
items = node.get(path[-1])
if not isinstance(items, list):
items = []
node[path[-1]] = items
return items
def ensure_object(document: dict, key: str) -> dict:
value = document.get(key)
if not isinstance(value, dict):
value = {}
document[key] = value
return value
def tags(document: dict, kind: str) -> list[str]:
"""Tags declared in the document for an ``x-tag-reference`` kind."""
result: list[str] = []
for section in TAGGED_SECTIONS.get(kind, ()):
for item in section_items(document, section):
if not isinstance(item, dict):
continue
raw = item.get("tag")
for tag in raw if isinstance(raw, list) else [raw]:
if isinstance(tag, str) and tag and tag not in result:
result.append(tag)
return result
def as_list(value: Any) -> list:
"""Normalise a native ``Listable`` value to a list."""
if value is None:
return []
return list(value) if isinstance(value, list) else [value]
def store_list(target: dict, key: str, items: list, shape: Shape) -> None:
"""Write list ``items`` back, keeping the form the file already used.
A listable field that held one bare value keeps being a bare value while it
has one item, so an untouched ``"network": "tcp"`` is not rewritten to
``["tcp"]``. Empty lists remove the key.
"""
if not items:
target.pop(key, None)
return
previous = target.get(key)
if shape.listable and len(items) == 1 and previous is not None and not isinstance(previous, list):
target[key] = items[0]
return
target[key] = list(items)
def app_owned_note(section: str, item: dict) -> str:
"""Explain what the application does with an item at launch, if anything."""
if not isinstance(item, dict):
return ""
kind = item.get("type")
if section == "outbounds" and item.get("tag") == PROXY_TAG:
return "Сюда при запуске подставляется выбранный сервер. Тип и параметры этого outbound задаёт приложение."
if section == "inbounds" and kind in APP_PROXY_INBOUND_TYPES:
return (
"Входящие socks/http/mixed приложение удаляет при запуске и само открывает "
"порты системного прокси."
)
if section == "inbounds" and kind == "tun":
return (
"Используется в режиме TUN. Имя интерфейса при запуске заменяется уникальным; "
"в режиме системного прокси TUN отключается."
)
tag = item.get("tag")
required = LAUNCH_REQUIRED_TAGS.get(section, {}).get(tag) if isinstance(tag, str) else None
if required:
return f"{required} Не удаляйте и не переименовывайте его."
if isinstance(tag, str) and tag.startswith(RESERVED_TAG_PREFIXES):
return "Тег зарезервирован приложением и может быть перезаписан при запуске."
return ""
def section_differs(document: dict, stock: dict | None, key: str) -> bool:
if stock is None:
return False
return document.get(key) != stock.get(key)
def differing_sections(document: dict, stock: dict | None) -> list[str]:
if stock is None:
return []
keys = list(document) + [key for key in stock if key not in document]
return [key for key in keys if document.get(key) != stock.get(key)]