Skip to content

Преобразование JSON в Protobuf

Этот документ описывает, как Xray преобразует человекочитаемую JSON-конфигурацию во внутреннее protobuf-представление, используемое ядром. Преобразование — это двухфазный процесс: десериализация JSON в промежуточные Go-структуры, за которой следуют вызовы Build(), создающие protobuf-сообщения.

Общий поток

mermaid
flowchart TD
    A[Config file] --> B{Format?}
    B -->|JSON| C[DecodeJSONConfig]
    B -->|YAML| D[DecodeYAMLConfig]
    B -->|TOML| E[DecodeTOMLConfig]
    B -->|Protobuf| F[loadProtobufConfig]

    C --> G["conf.Config{}"]
    D -->|yaml->json| C
    E -->|toml->map->json| C
    F --> H["core.Config{}"]

    G --> I["conf.Config.Build()"]
    I --> H

    H --> J["core.New(config)"]
    J --> K["CreateObject() for each App"]
    K --> L[Running Xray Instance]

Фаза 1: JSON в Go-структуры

Файл: infra/conf/serial/loader.go

go
func DecodeJSONConfig(reader io.Reader) (*conf.Config, error) {
    jsonConfig := &conf.Config{}

    jsonReader := io.TeeReader(&json_reader.Reader{Reader: reader}, jsonContent)
    decoder := json.NewDecoder(jsonReader)

    if err := decoder.Decode(jsonConfig); err != nil {
        // Расширенные сообщения об ошибках с позицией строки/символа
        var pos *offset
        switch tErr := cause.(type) {
        case *json.SyntaxError:
            pos = findOffset(jsonContent.Bytes(), int(tErr.Offset))
        case *json.UnmarshalTypeError:
            pos = findOffset(jsonContent.Bytes(), int(tErr.Offset))
        }
    }
    return jsonConfig, nil
}

JSON-ридер сначала удаляет комментарии, затем стандартный декодер encoding/json заполняет структуру conf.Config. Подобъекты, такие как Settings в конфигурациях inbound/outbound, сохраняются как *json.RawMessage для отложенного протокол-специфичного парсинга.

Фаза 2: Go-структуры в Protobuf

Метод conf.Config.Build() оркестрирует преобразование. Каждая секция следует одному и тому же шаблону: валидация, построение protobuf, обёртка в TypedMessage.

Обёртка TypedMessage

Пакет: common/serial

Каждое protobuf-сообщение оборачивается в TypedMessage перед добавлением в core.Config:

go
type TypedMessage struct {
    Type  string  // полный URL типа protobuf
    Value []byte  // сериализованные байты protobuf
}

func ToTypedMessage(message proto.Message) *TypedMessage {
    // Сериализует сообщение и сохраняет его URL типа
}

Это ключевая абстракция, позволяющая размещать разнородные объекты конфигурации в одном списке. Поле core.Config.App имеет тип []*TypedMessage, где каждая запись может быть любым protobuf-типом.

CreateObject: из TypedMessage в живой объект

Пакет: common

go
func CreateObject(ctx context.Context, config interface{}) (interface{}, error) {
    // Ищет зарегистрированную фабрику для типа конфигурации
    // Вызывает фабричную функцию для создания объекта времени выполнения
}

При запуске экземпляра core.New() перебирает config.App и вызывает:

go
for _, appSettings := range config.App {
    obj, _ := CreateObject(ctx, appSettings)
    // Регистрация объекта как функционального модуля
}

Фабрика была зарегистрирована в init() каждого пакета:

go
// Пример из app/dns/dns.go
func init() {
    common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) {
        return New(ctx, config.(*Config))
    }))
}

Посекционное сопоставление

Конфигурация DNS

JSON:

json
{
    "dns": {
        "servers": [
            { "address": "8.8.8.8", "port": 53, "domains": ["geosite:google"] },
            "1.1.1.1"
        ],
        "hosts": { "example.com": "1.2.3.4" },
        "queryStrategy": "UseIP",
        "disableCache": false
    }
}

Go-структура: conf.DNSConfig -> Protobuf: dns.Config

Файл: infra/conf/dns.go

NameServerConfig имеет пользовательский UnmarshalJSON, принимающий как простую строку ("1.1.1.1"), так и полный объект. Метод Build():

  1. Парсит правила доменов через parseDomainRule(), обрабатывая префиксы geosite:, domain:, full:, regexp:, keyword:
  2. Строит expectedIPs и unexpectedIPs через ToCidrList()
  3. Создаёт protobuf dns.NameServer с эндпоинтом, правилами доменов и матчерами GeoIP
  4. Вычисляет policyID для группировки параллельных запросов

Конфигурация Inbound

JSON:

json
{
    "inbounds": [{
        "protocol": "vless",
        "port": 443,
        "listen": "0.0.0.0",
        "settings": { "clients": [...] },
        "streamSettings": { "network": "tcp", "security": "tls" },
        "sniffing": { "enabled": true, "destOverride": ["http", "tls"] }
    }]
}

Go-структура: conf.InboundDetourConfig -> Protobuf: core.InboundHandlerConfig

Процесс сборки:

go
func (c *InboundDetourConfig) Build() (*core.InboundHandlerConfig, error) {
    // 1. Построение ReceiverConfig (прослушивание, порт, поток, сниффинг)
    receiverSettings := &proxyman.ReceiverConfig{}

    // 2. Загрузка протокол-специфичной конфигурации
    rawConfig, _ := inboundConfigLoader.LoadWithID(settings, c.Protocol)
    // rawConfig — это, например, *VLessInboundConfig

    // 3. Построение protobuf протокола
    ts, _ := rawConfig.(Buildable).Build()
    // ts — это, например, *vless.ServerConfig (protobuf)

    // 4. Обёртка обоих в TypedMessage
    return &core.InboundHandlerConfig{
        Tag:              c.Tag,
        ReceiverSettings: serial.ToTypedMessage(receiverSettings),
        ProxySettings:    serial.ToTypedMessage(ts),
    }, nil
}

Конфигурация Outbound

JSON:

json
{
    "outbounds": [{
        "protocol": "vless",
        "tag": "proxy",
        "settings": { "vnext": [...] },
        "streamSettings": { "network": "tcp" },
        "mux": { "enabled": true, "concurrency": 8 }
    }]
}

Go-структура: conf.OutboundDetourConfig -> Protobuf: core.OutboundHandlerConfig

Процесс сборки включает:

  1. SenderConfig с адресом via, настройками потока, настройками mux и цепочкой прокси
  2. targetStrategy сопоставляется с перечислением internet.DomainStrategy
  3. Протокол-специфичные настройки загружаются через outboundConfigLoader

Конфигурация API

JSON:

json
{
    "api": {
        "tag": "api",
        "listen": "127.0.0.1:10085",
        "services": ["HandlerService", "StatsService"]
    }
}

Go-структура: conf.APIConfig -> Protobuf: commander.Config

Файл: infra/conf/api.go

Сервисы сопоставляются по имени с их protobuf-типами конфигурации:

go
func (c *APIConfig) Build() (*commander.Config, error) {
    services := make([]*serial.TypedMessage, 0)
    for _, s := range c.Services {
        switch strings.ToLower(s) {
        case "handlerservice":
            services = append(services, serial.ToTypedMessage(&handlerservice.Config{}))
        case "statsservice":
            services = append(services, serial.ToTypedMessage(&statsservice.Config{}))
        // ...
        }
    }
    return &commander.Config{Tag: c.Tag, Listen: c.Listen, Service: services}, nil
}

Конфигурация маршрутизации

Go-структура: conf.RouterConfig -> Protobuf: router.Config

Правила маршрутизации парсятся из JSON с поддержкой списков доменов, списков IP, диапазонов портов, email пользователей, имён протоколов и атрибутов. Метод Build() каждого правила создаёт protobuf router.RoutingRule.

Protobuf core.Config

Файл: core/config.go

protobuf
message Config {
    repeated InboundHandlerConfig inbound = 1;
    repeated OutboundHandlerConfig outbound = 2;
    repeated TypedMessage app = 4;
}

Список App — это основная точка расширения. Любой функциональный модуль (DNS, маршрутизация, статистика, Observatory, обратный прокси, Commander) добавляется сюда как TypedMessage. Ядро перебирает этот список при запуске и создаёт каждый модуль.

Регистрация форматов

Файл: core/config.go

go
type ConfigFormat struct {
    Name      string
    Extension []string
    Loader    ConfigLoader
}

func RegisterConfigLoader(format *ConfigFormat) error {
    configLoaderByName[name] = format
    for _, ext := range format.Extension {
        configLoaderByExt[ext] = format
    }
}

Файл: infra/conf/serial/builder.go

go
func init() {
    ReaderDecoderByFormat["json"] = DecodeJSONConfig
    ReaderDecoderByFormat["yaml"] = DecodeYAMLConfig
    ReaderDecoderByFormat["toml"] = DecodeTOMLConfig

    core.ConfigBuilderForFiles = BuildConfig
    core.ConfigMergedFormFiles = MergeConfigFromFiles
}

Функция BuildConfig связывает всё вместе:

go
func BuildConfig(files []*core.ConfigSource) (*core.Config, error) {
    config, _ := mergeConfigs(files)
    return config.Build()
}

Прямая загрузка Protobuf

Бинарные protobuf-конфигурации (файлы .pb) полностью пропускают слой JSON:

go
func loadProtobufConfig(data []byte) (*Config, error) {
    config := new(Config)
    proto.Unmarshal(data, config)
    return config, nil
}

Допускается только один protobuf-файл (объединение невозможно), и он должен содержать полный core.Config.

Ключевые шаблоны преобразования

Типы адресов

Тип conf.Address обрабатывает IP-адреса, домены и специальные значения. Его метод Build() создаёт net.IPOrDomain:

go
// "1.2.3.4" -> net.IPOrDomain{Address: &IPOrDomain_Ip{Ip: [4]byte}}
// "example.com" -> net.IPOrDomain{Address: &IPOrDomain_Domain{Domain: "example.com"}}

Списки портов

conf.PortList парсит диапазоны вида "1024-65535" или перечисления через запятую "80,443,8080" и создаёт net.PortList с записями net.PortRange.

Списки строк

conf.StringList — это []string с пользовательской JSON-десериализацией, принимающей как одну строку, так и массив строк.

Правила доменов

Строки доменов парсятся с определением префикса:

  • "domain:example.com" -> Сопоставление по поддомену
  • "full:example.com" -> Точное сопоставление домена
  • "regexp:.*\\.example\\.com" -> Сопоставление по регулярному выражению
  • "keyword:example" -> Сопоставление по ключевому слову
  • "geosite:category" -> Внешний список geosite
  • "ext:filename:list" -> Список доменов из внешнего файла
  • Без префикса -> Обрабатывается как поддомен или geosite (зависит от контекста)

Заметки по реализации

  • Преобразование JSON в protobuf строго однонаправленное. Обратного преобразования работающего core.Config в JSON не предусмотрено (хотя MergeConfigFromFiles может создавать объединённый JSON для отображения с использованием рефлексии).

  • Шаблон json.RawMessage для настроек протоколов критически важен: он позволяет парсить внешнюю конфигурацию без знания типа протокола, а затем внутренние настройки парсятся, когда протокол уже известен.

  • Сообщения об ошибках из Build() включают контекст о том, какая секция вызвала ошибку (например, "failed to build DNS configuration"), что упрощает отладку конфигурации.

  • Пути YAML и TOML в конечном счёте создают JSON и передают его в DecodeJSONConfig. Это означает, что все YAML/TOML-конфигурации должны быть представимы в виде валидных JSON-структур. Сложные возможности YAML (якоря, мультидокументы) могут не работать.

  • Система common.RegisterConfig / common.CreateObject действует как глобальный контейнер внедрения зависимостей. URL типов protobuf служат ключами, а фабричные функции создают объекты времени выполнения.

  • core.RequireFeatures() обеспечивает отложенную инициализацию: фабрика функционального модуля может запросить другие модули, которые ещё не были созданы. Ядро разрешает эти зависимости после регистрации всех модулей, вызывая отложенные callback-функции.

Технический анализ для целей повторной реализации.