Some checks failed
Windows project source guards / test (push) Failing after 38s
Раздел «Маршрутизация» с подпунктами (Обзор, Правила, Наборы правил, DNS, Исходящие, Система, JSON) редактирует тот же активный native JSON. Формы строятся из JSON Schema закреплённого ядра (sing-box schema), своей модели маршрутизации нет; неизвестные ключи и форма Listable сохраняются. «Проверить» запускает sing-box check в фоне. DNS больше не перезаписывается принудительно: при обновлении шаблонов нетронутые верхние секции следуют за стоком, изменённые остаются. Сброс к стоку — вручную, целиком или по разделу.
337 lines
12 KiB
Python
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"))
|