zapret-kvn/xray_fluent/singbox_config/schema.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

337 lines
12 KiB
Python

"""Read the JSON Schema generated by the bundled sing-box core.
The structured routing GUI never describes sing-box options itself. It asks
this module which fields an object accepts, of which type, with which enum
values and which tag kind a field refers to. The schema is produced by
``sing-box schema`` (see ``scripts/generate_singbox_schema.py``), so the GUI
follows the pinned core exactly.
Only a small subset of JSON Schema is used by the generator: ``$ref``,
``allOf``, ``anyOf``, ``oneOf``, ``enum``, ``const``, ``items``, ``properties``,
``required``, ``minimum``/``maximum``, ``pattern`` and ``x-tag-reference``.
Discriminated unions (``type`` of inbounds/outbounds/DNS servers/rule-sets,
``action`` of rules) are ``oneOf`` whose variants carry a ``const``/``enum``
for the discriminator key.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from functools import lru_cache
import json
from pathlib import Path
from typing import Any
from ..constants import ASSETS_DIR
SCHEMA_DIR = ASSETS_DIR / "sing-box-schema"
SCHEMA_PATH = SCHEMA_DIR / "schema.json"
DISCRIMINATOR_KEYS = ("type", "action")
# Named definitions with a dedicated value kind.
_NAMED_KINDS = {"Duration": "duration"}
@dataclass(frozen=True)
class Shape:
"""Value shape of one field.
``kind`` is one of ``bool``, ``int``, ``string``, ``enum``, ``duration``,
``const``, ``object``, ``map``, ``array``, ``union`` or ``any``.
``listable`` fields accept either one ``item`` or a list of them — the
native sing-box ``Listable`` convention.
"""
kind: str
enum: tuple = ()
tag_ref: str | None = None
minimum: int | None = None
maximum: int | None = None
listable: bool = False
item: "Shape | None" = None
node: dict | None = field(default=None, compare=False, hash=False)
alternatives: tuple["Shape", ...] = ()
deprecated: bool = False
const: Any = None
@property
def scalar(self) -> bool:
return self.kind in {"bool", "int", "string", "enum", "duration"}
@dataclass(frozen=True)
class Field:
name: str
shape: Shape
required: bool = False
@dataclass(frozen=True)
class FieldGroup:
"""Fields contributed by one ``allOf`` member.
A route rule is ``allOf[match fields, RuleAction]``: the first group holds
the match conditions, the second the action and its options. Plain objects
have a single group.
"""
fields: tuple[Field, ...]
discriminator: str | None = None
variants: tuple[str, ...] = ()
default_variant: str | None = None
def get(self, name: str) -> Field | None:
for item in self.fields:
if item.name == name:
return item
return None
@dataclass(frozen=True)
class Variant:
key: str
value: str
class SingboxSchema:
def __init__(self, document: dict):
self.document = document
self.defs: dict[str, dict] = document.get("$defs", {})
@classmethod
def load(cls, path: Path = SCHEMA_PATH) -> "SingboxSchema":
return cls(json.loads(path.read_text(encoding="utf-8")))
# -- references ---------------------------------------------------------
def ref_name(self, node: dict) -> str | None:
ref = node.get("$ref") if isinstance(node, dict) else None
if isinstance(ref, str) and ref.startswith("#/$defs/"):
return ref[len("#/$defs/"):]
return None
def resolve(self, node: dict) -> dict:
seen = 0
while isinstance(node, dict) and "$ref" in node and seen < 32:
name = self.ref_name(node)
node = self.defs.get(name or "", {})
seen += 1
return node if isinstance(node, dict) else {}
def definition(self, name: str) -> dict:
return self.defs.get(name, {})
# -- navigation from the document root ---------------------------------
def root_field(self, name: str) -> Field | None:
node = self.document.get("properties", {}).get(name)
if node is None:
return None
return Field(name, self.shape(node))
def section(self, name: str) -> dict:
return self.resolve(self.document.get("properties", {}).get(name, {}))
def array_item(self, section: str) -> dict:
"""Item schema of a top-level array section such as ``outbounds``."""
node = self.section(section)
return self.resolve(node.get("items", {}))
# -- shapes -------------------------------------------------------------
def shape(self, node: dict) -> Shape:
if not isinstance(node, dict) or not node:
return Shape("any")
name = self.ref_name(node)
if name is not None:
kind = _NAMED_KINDS.get(name)
if kind is not None:
return Shape(kind)
return self.shape(self.resolve(node))
deprecated = bool(node.get("deprecated"))
tag_ref = node.get("x-tag-reference")
if "const" in node:
return Shape("const", const=node["const"])
if "enum" in node:
values = tuple(value for value in node["enum"] if value != "")
return Shape("enum", enum=values, tag_ref=tag_ref, deprecated=deprecated)
if "anyOf" in node:
return self._any_of_shape(node["anyOf"], deprecated)
if "oneOf" in node or "allOf" in node:
return Shape("object", node=node, deprecated=deprecated)
kind = node.get("type")
if kind == "boolean":
return Shape("bool", deprecated=deprecated)
if kind == "integer":
return Shape(
"int",
minimum=node.get("minimum"),
maximum=node.get("maximum"),
deprecated=deprecated,
)
if kind == "string":
return Shape("string", tag_ref=tag_ref, deprecated=deprecated)
if kind == "array":
return Shape("array", item=self.shape(node.get("items", {})), deprecated=deprecated)
if kind == "object":
if node.get("properties"):
return Shape("object", node=node, deprecated=deprecated)
return Shape("map", node=node, deprecated=deprecated)
return Shape("any", node=node)
def _any_of_shape(self, options: list, deprecated: bool) -> Shape:
if len(options) == 2:
first, second = options
if (
isinstance(second, dict)
and second.get("type") == "array"
and self.resolve(second.get("items", {})) == self.resolve(first)
):
item = self.shape(first)
return Shape(
item.kind,
enum=item.enum,
tag_ref=item.tag_ref,
minimum=item.minimum,
maximum=item.maximum,
listable=True,
item=item,
node=item.node,
alternatives=item.alternatives,
deprecated=deprecated or item.deprecated,
)
alternatives = tuple(self.shape(option) for option in options)
return Shape("union", alternatives=alternatives, deprecated=deprecated)
# -- objects ------------------------------------------------------------
def discriminator(self, node: dict) -> str | None:
node = self.resolve(node)
variants = node.get("oneOf")
if not variants:
return None
for key in DISCRIMINATOR_KEYS:
if all(self._discriminator_values(variant, key) is not None for variant in variants):
return key
return None
def variants(self, node: dict) -> list[Variant]:
node = self.resolve(node)
key = self.discriminator(node)
if key is None:
return []
result: list[Variant] = []
for variant in node.get("oneOf", []):
for value in self._discriminator_values(variant, key) or ():
if value != "":
result.append(Variant(key, value))
return result
def default_variant(self, node: dict) -> str | None:
"""Discriminator value the core assumes when the key is absent."""
node = self.resolve(node)
key = self.discriminator(node)
if key is None:
return None
for variant in node.get("oneOf", []):
values = self._discriminator_values(variant, key) or ()
if "" in values or key not in self._required(variant):
return next((value for value in values if value != ""), None)
return None
def groups(self, node: dict, value: Any) -> list[FieldGroup]:
"""Field groups the object accepts for its current discriminator."""
value = value if isinstance(value, dict) else {}
node = self.resolve(node)
if "oneOf" in node:
key = self.discriminator(node)
variant = self._select_variant(node["oneOf"], key, value)
groups = self.groups(variant, value)
if key is not None and groups and groups[0].discriminator is None:
groups[0] = FieldGroup(
groups[0].fields,
discriminator=key,
variants=tuple(item.value for item in self.variants(node)),
default_variant=self.default_variant(node),
)
return groups
if "allOf" in node:
result: list[FieldGroup] = []
for member in node["allOf"]:
result.extend(self.groups(member, value))
return result
required = set(node.get("required", ()))
fields = tuple(
Field(name, self.shape(child), name in required)
for name, child in (node.get("properties") or {}).items()
)
return [FieldGroup(fields)] if fields else []
def fields(self, node: dict, value: Any) -> dict[str, Field]:
result: dict[str, Field] = {}
for group in self.groups(node, value):
for item in group.fields:
result.setdefault(item.name, item)
return result
def _required(self, node: dict) -> set[str]:
node = self.resolve(node)
if "oneOf" in node:
options = [self._required(option) for option in node["oneOf"]]
return set.intersection(*options) if options else set()
required = set(node.get("required", ()))
for member in node.get("allOf", ()):
member = self.resolve(member)
if "properties" in member:
required |= set(member.get("required", ()))
return required
def _discriminator_values(self, variant: dict, key: str) -> tuple | None:
variant = self.resolve(variant)
if "oneOf" in variant:
nested: list = []
for option in variant["oneOf"]:
values = self._discriminator_values(option, key)
if values is None:
return None
nested.extend(value for value in values if value not in nested)
return tuple(nested)
members = [variant, *[self.resolve(member) for member in variant.get("allOf", ())]]
for member in members:
prop = (member.get("properties") or {}).get(key)
if not isinstance(prop, dict):
continue
if "const" in prop:
return (prop["const"],)
if "enum" in prop:
return tuple(prop["enum"])
return None
def _select_variant(self, variants: list, key: str | None, value: dict) -> dict:
if key is None:
return variants[0] if variants else {}
current = value.get(key)
if current is not None:
for variant in variants:
if current in (self._discriminator_values(variant, key) or ()):
return variant
return {}
for variant in variants:
values = self._discriminator_values(variant, key) or ()
if "" in values or key not in self._required(variant):
return variant
return variants[0] if variants else {}
@lru_cache(maxsize=1)
def bundled_schema() -> SingboxSchema:
return SingboxSchema.load()
def schema_meta(path: Path = SCHEMA_DIR / "meta.json") -> dict:
return json.loads(path.read_text(encoding="utf-8"))