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
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
endCommander Core
File: app/commander/commander.go
type Commander struct {
sync.Mutex
server *grpc.Server
services []Service
ohm outbound.Manager
tag string
listen string
}Service Interface
// app/commander/service.go
type Service interface {
Register(*grpc.Server)
}Every gRPC service implementation must satisfy this interface to register its handlers.
Initialization
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
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:
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:
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
type statsServer struct {
stats feature_stats.Manager
startTime time.Time
}gRPC methods:
| Method | Description |
|---|---|
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:
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:
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:
type reflectionService struct{}
func (r reflectionService) Register(s *grpc.Server) {
reflection.Register(s)
}Configuration
File: infra/conf/api.go
{
"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:
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:
- A
dokodemo-doorinbound tagged with the API tag - A routing rule directing that inbound to the API outbound
{
"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 requiresoutbound.Managerfrom the feature registry.The
OutboundListenerbuffer 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
listenfield enables direct TCP mode, which is simpler to set up but bypasses Xray's routing system. In this mode, no outbound handler is registered.