Skip to content

JSON to Protobuf Conversion ​

This document describes how Xray converts human-readable JSON configuration into the internal protobuf representation used by the core engine. The conversion is a two-phase process: JSON deserialization into intermediate Go structs, followed by Build() calls that produce protobuf messages.

Overall Flow ​

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]

Phase 1: JSON to Go Structs ​

File: 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 {
        // Enhanced error reporting with line/char position
        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
}

The JSON reader strips comments first, then the standard encoding/json decoder fills the conf.Config struct. Sub-objects like Settings on inbound/outbound configs are kept as *json.RawMessage for deferred protocol-specific parsing.

Phase 2: Go Structs to Protobuf ​

The conf.Config.Build() method orchestrates the conversion. Each section follows the same pattern: validate, build the protobuf, wrap in TypedMessage.

TypedMessage Wrapping ​

Package: common/serial

Every protobuf message is wrapped in TypedMessage before being added to core.Config:

go
type TypedMessage struct {
    Type  string  // full protobuf type URL
    Value []byte  // serialized protobuf bytes
}

func ToTypedMessage(message proto.Message) *TypedMessage {
    // Marshals the message and stores its type URL
}

This is the key abstraction that allows heterogeneous config objects in a single list. The core.Config.App field is []*TypedMessage, where each entry can be any protobuf type.

CreateObject: TypedMessage to Live Object ​

Package: common

go
func CreateObject(ctx context.Context, config interface{}) (interface{}, error) {
    // Looks up the registered factory for the config's type
    // Calls the factory function to create the runtime object
}

During instance startup, core.New() iterates config.App and calls:

go
for _, appSettings := range config.App {
    obj, _ := CreateObject(ctx, appSettings)
    // Register the object as a feature
}

The factory was registered in each package's init():

go
// Example from 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))
    }))
}

Section-by-Section Mapping ​

DNS Configuration ​

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 struct: conf.DNSConfig -> Protobuf: dns.Config

File: infra/conf/dns.go

The NameServerConfig has a custom UnmarshalJSON that accepts both a simple string ("1.1.1.1") and a full object. The Build() method:

  1. Parses domain rules via parseDomainRule() which handles prefixes like geosite:, domain:, full:, regexp:, keyword:
  2. Builds expectedIPs and unexpectedIPs via ToCidrList()
  3. Constructs dns.NameServer protobuf with endpoint, domain rules, and GeoIP matchers
  4. Computes policyID for parallel query grouping

Inbound Configuration ​

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 struct: conf.InboundDetourConfig -> Protobuf: core.InboundHandlerConfig

The build process:

go
func (c *InboundDetourConfig) Build() (*core.InboundHandlerConfig, error) {
    // 1. Build ReceiverConfig (listen, port, stream, sniffing)
    receiverSettings := &proxyman.ReceiverConfig{}

    // 2. Load protocol-specific config
    rawConfig, _ := inboundConfigLoader.LoadWithID(settings, c.Protocol)
    // rawConfig is e.g. *VLessInboundConfig

    // 3. Build protocol protobuf
    ts, _ := rawConfig.(Buildable).Build()
    // ts is e.g. *vless.ServerConfig (protobuf)

    // 4. Wrap both in TypedMessage
    return &core.InboundHandlerConfig{
        Tag:              c.Tag,
        ReceiverSettings: serial.ToTypedMessage(receiverSettings),
        ProxySettings:    serial.ToTypedMessage(ts),
    }, nil
}

Outbound Configuration ​

JSON:

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

Go struct: conf.OutboundDetourConfig -> Protobuf: core.OutboundHandlerConfig

Build process includes:

  1. SenderConfig with via address, stream settings, mux settings, and proxy chain
  2. targetStrategy maps to internet.DomainStrategy enum
  3. Protocol-specific settings loaded via outboundConfigLoader

API Configuration ​

JSON:

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

Go struct: conf.APIConfig -> Protobuf: commander.Config

File: infra/conf/api.go

Services are mapped by name to their protobuf config types:

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
}

Routing Configuration ​

Go struct: conf.RouterConfig -> Protobuf: router.Config

Routing rules are parsed from JSON with support for domain lists, IP lists, port ranges, user emails, protocol names, and attributes. Each rule's Build() creates a router.RoutingRule protobuf.

The core.Config Protobuf ​

File: core/config.go

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

The App list is the core extension point. Any feature (DNS, routing, stats, observatory, reverse proxy, commander) is added here as a TypedMessage. The core iterates this list during startup and creates each feature.

Format Registration ​

File: 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
    }
}

File: infra/conf/serial/builder.go

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

    core.ConfigBuilderForFiles = BuildConfig
    core.ConfigMergedFormFiles = MergeConfigFromFiles
}

The BuildConfig function ties everything together:

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

Direct Protobuf Loading ​

Binary protobuf configs (.pb files) skip the JSON layer entirely:

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

Only one protobuf file is allowed (no merging), and it must contain the complete core.Config.

Key Conversion Patterns ​

Address Types ​

The conf.Address type handles IP addresses, domains, and special values. Its Build() produces 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"}}

Port Lists ​

conf.PortList parses ranges like "1024-65535" or comma-separated "80,443,8080" and builds net.PortList with net.PortRange entries.

String Lists ​

conf.StringList is a []string with custom JSON unmarshaling that accepts both a single string and an array of strings.

Domain Rules ​

Domain strings are parsed with prefix detection:

  • "domain:example.com" -> Subdomain matching
  • "full:example.com" -> Full domain matching
  • "regexp:.*\\.example\\.com" -> Regex matching
  • "keyword:example" -> Keyword matching
  • "geosite:category" -> External geosite list
  • "ext:filename:list" -> External file domain list
  • No prefix -> Treated as subdomain or geosite (context-dependent)

Implementation Notes ​

  • The JSON-to-protobuf conversion is strictly one-way. There is no facility to convert a running core.Config back to JSON (though MergeConfigFromFiles can produce merged JSON for display purposes using reflection).

  • The json.RawMessage pattern for protocol settings is crucial: it allows the outer config to be parsed without knowing the protocol type, then the inner settings are parsed once the protocol is known.

  • Error messages from Build() include context about which section failed (e.g., "failed to build DNS configuration"), making configuration debugging easier.

  • The YAML and TOML paths both ultimately produce JSON and feed it to DecodeJSONConfig. This means all YAML/TOML configs must be representable as valid JSON structures. Complex YAML features (anchors, multi-document) may not work.

  • The common.RegisterConfig / common.CreateObject system acts as a global dependency injection container. Protobuf type URLs serve as the keys, and factory functions produce the runtime objects.

  • core.RequireFeatures() allows deferred initialization: a feature's factory can request other features that haven't been created yet. The core resolves these dependencies after all features are registered, triggering the deferred callbacks.

Technical analysis for re-implementation purposes.