Skip to content

Commander: gRPC API for Runtime Management

The Commander is Xray's built-in gRPC server that exposes runtime management APIs. It enables dynamic configuration changes, statistics retrieval, and system monitoring without restarting the process.

Architecture

mermaid
flowchart TD
    subgraph Commander
        C[Commander] --> GS[grpc.Server]
        GS --> HS[HandlerService]
        GS --> SS[StatsService]
        GS --> RS[RoutingService]
        GS --> LS[LoggerService]
        GS --> OS[ObservatoryService]
        GS --> RF[ReflectionService]
    end

    subgraph Transport
        GS -->|option A| OL[OutboundListener]
        OL --> OH[Outbound Handler]
        OH --> RT[Xray Routing]

        GS -->|option B| TL[TCP Listener]
        TL --> NW[Direct network]
    end

    subgraph Client
        CLI[gRPC Client] --> RT
        CLI2[gRPC Client] --> NW
    end

Commander Core

File: app/commander/commander.go

go
type Commander struct {
    sync.Mutex
    server   *grpc.Server
    services []Service
    ohm      outbound.Manager
    tag      string
    listen   string
}

Service Interface

go
// app/commander/service.go
type Service interface {
    Register(*grpc.Server)
}

Every gRPC service implementation must satisfy this interface to register its handlers.

Initialization

go
func NewCommander(ctx context.Context, config *Config) (*Commander, error) {
    c := &Commander{tag: config.Tag, listen: config.Listen}

    core.RequireFeatures(ctx, func(om outbound.Manager) {
        c.ohm = om
    })

    for _, rawConfig := range config.Service {
        config, _ := rawConfig.GetInstance()             // TypedMessage -> proto.Message
        rawService, _ := common.CreateObject(ctx, config) // proto.Message -> Service
        service, _ := rawService.(Service)
        c.services = append(c.services, service)
    }
}

Each service is created from its protobuf config via the global config registry (common.CreateObject).

Start: Two Transport Modes

go
func (c *Commander) Start() error {
    c.server = grpc.NewServer()
    for _, service := range c.services {
        service.Register(c.server)
    }

    if len(c.listen) > 0 {
        // Direct TCP listener mode
        l, _ := net.Listen("tcp", c.listen)
        go c.server.Serve(l)
        return nil
    }

    // Outbound listener mode (through Xray routing)
    listener := &OutboundListener{
        buffer: make(chan net.Conn, 4),
        done:   done.New(),
    }
    go c.server.Serve(listener)
    c.ohm.RemoveHandler(context.Background(), c.tag)
    return c.ohm.AddHandler(context.Background(), &Outbound{
        tag:      c.tag,
        listener: listener,
    })
}

Mode 1 -- Direct TCP (listen field set): Opens a real TCP socket. Simpler but exposed on the network.

Mode 2 -- Outbound Handler (default): Creates a virtual OutboundListener and registers an Outbound handler. gRPC clients connect through Xray's inbound/routing system.

OutboundListener

File: app/commander/outbound.go

A net.Listener backed by a channel of connections:

go
type OutboundListener struct {
    buffer chan net.Conn  // capacity: 4
    done   *done.Instance
}

func (l *OutboundListener) Accept() (net.Conn, error) {
    select {
    case <-l.done.Wait():
        return nil, errors.New("listen closed")
    case c := <-l.buffer:
        return c, nil
    }
}

Outbound Handler

Converts Xray transport links into net.Conn for the gRPC server:

go
type Outbound struct {
    tag      string
    listener *OutboundListener
    access   sync.RWMutex
    closed   bool
}

func (co *Outbound) Dispatch(ctx context.Context, link *transport.Link) {
    closeSignal := done.New()
    c := cnc.NewConnection(
        cnc.ConnectionInputMulti(link.Writer),
        cnc.ConnectionOutputMulti(link.Reader),
        cnc.ConnectionOnClose(closeSignal),
    )
    co.listener.add(c)
    <-closeSignal.Wait()  // Block until connection closes
}

Available Services

StatsService

File: app/stats/command/command.go

go
type statsServer struct {
    stats     feature_stats.Manager
    startTime time.Time
}

gRPC methods:

MethodDescription
GetStats(name, reset)Get a single counter's value, optionally resetting it
QueryStats(pattern, reset)Query all counters matching a substring pattern
GetSysStats()System stats: uptime, goroutines, memory allocation, GC
GetStatsOnline(name)Get online user count for a user's online map
GetStatsOnlineIpList(name)Get IP list with timestamps for an online map
GetAllOnlineUsers()List all users with active IPs

The GetSysStats method provides runtime diagnostics:

go
func (s *statsServer) GetSysStats(ctx context.Context, request *SysStatsRequest) (*SysStatsResponse, error) {
    var rtm runtime.MemStats
    runtime.ReadMemStats(&rtm)
    return &SysStatsResponse{
        Uptime:       uint32(time.Since(s.startTime).Seconds()),
        NumGoroutine: uint32(runtime.NumGoroutine()),
        Alloc:        rtm.Alloc,
        TotalAlloc:   rtm.TotalAlloc,
        Sys:          rtm.Sys,
        Mallocs:      rtm.Mallocs,
        Frees:        rtm.Frees,
        LiveObjects:  rtm.Mallocs - rtm.Frees,
        NumGC:        rtm.NumGC,
        PauseTotalNs: rtm.PauseTotalNs,
    }, nil
}

For v2ray compatibility, the StatsService registers twice -- under both xray.app.stats.command.StatsService and v2ray.core.app.stats.command.StatsService:

go
func (s *service) Register(server *grpc.Server) {
    ss := NewStatsServer(s.statsManager)
    RegisterStatsServiceServer(server, ss)
    vCoreDesc := StatsService_ServiceDesc
    vCoreDesc.ServiceName = "v2ray.core.app.stats.command.StatsService"
    server.RegisterService(&vCoreDesc, ss)
}

HandlerService

Package: app/proxyman/command

Manages inbound and outbound handlers at runtime:

  • Add/remove inbound handlers
  • Add/remove outbound handlers
  • Alter inbound handler settings

RoutingService

Package: app/router/command

Runtime routing management:

  • Test routing rules against specific contexts
  • Query the routing table

LoggerService

Package: app/log/command

  • Restart the logger
  • Follow log output via streaming

ObservatoryService

Package: app/observatory/command

  • GetOutboundStatus(): Returns health status of all observed outbounds

ReflectionService

File: app/commander/service.go

Enables gRPC server reflection for tooling:

go
type reflectionService struct{}

func (r reflectionService) Register(s *grpc.Server) {
    reflection.Register(s)
}

Configuration

File: infra/conf/api.go

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

The Build() method maps service names to their protobuf configs:

go
func (c *APIConfig) Build() (*commander.Config, error) {
    for _, s := range c.Services {
        switch strings.ToLower(s) {
        case "reflectionservice":
            services = append(services, serial.ToTypedMessage(&commander.ReflectionConfig{}))
        case "handlerservice":
            services = append(services, serial.ToTypedMessage(&handlerservice.Config{}))
        case "statsservice":
            services = append(services, serial.ToTypedMessage(&statsservice.Config{}))
        // ... etc
        }
    }
}

When using outbound mode (no listen), you need:

  1. A dokodemo-door inbound tagged with the API tag
  2. A routing rule directing that inbound to the API outbound
json
{
    "inbounds": [{
        "tag": "api-in",
        "protocol": "dokodemo-door",
        "port": 10085,
        "settings": { "address": "127.0.0.1" }
    }],
    "routing": {
        "rules": [{
            "inboundTag": ["api-in"],
            "outboundTag": "api"
        }]
    }
}

Implementation Notes

  • The Commander registers itself via common.RegisterConfig((*Config)(nil), ...) and requires outbound.Manager from the feature registry.

  • The OutboundListener buffer has a capacity of 4. If the gRPC server is slow to accept and 4 connections are queued, additional connections are immediately closed (dropped).

  • The Outbound.Dispatch() method blocks on <-closeSignal.Wait() to keep the transport link alive for the duration of the gRPC connection.

  • When the Commander shuts down, c.server.Stop() is called which forcefully terminates all active gRPC streams. There is no graceful shutdown.

  • Service creation uses the generic common.CreateObject() path, which looks up the protobuf config type in the global registry. This means services can be extended by third-party packages that register new configs.

  • The listen field enables direct TCP mode, which is simpler to set up but bypasses Xray's routing system. In this mode, no outbound handler is registered.

Technical analysis for re-implementation purposes.