Browse Source

Add v2ray stats api

世界 3 years ago
parent
commit
1b44faed17

+ 10 - 0
adapter/experimental.go

@@ -38,3 +38,13 @@ func OutboundTag(detour Outbound) string {
 	}
 	return detour.Tag()
 }
+
+type V2RayServer interface {
+	Service
+	StatsService() V2RayStatsService
+}
+
+type V2RayStatsService interface {
+	RoutedConnection(inbound string, outbound string, conn net.Conn) net.Conn
+	RoutedPacketConnection(inbound string, outbound string, conn N.PacketConn) N.PacketConn
+}

+ 4 - 1
adapter/router.go

@@ -41,7 +41,10 @@ type Router interface {
 	Rules() []Rule
 
 	ClashServer() ClashServer
-	SetClashServer(controller ClashServer)
+	SetClashServer(server ClashServer)
+
+	V2RayServer() V2RayServer
+	SetV2RayServer(server V2RayServer)
 }
 
 type Rule interface {

+ 29 - 2
box.go

@@ -31,6 +31,7 @@ type Box struct {
 	logger      log.ContextLogger
 	logFile     *os.File
 	clashServer adapter.ClashServer
+	v2rayServer adapter.V2RayServer
 	done        chan struct{}
 }
 
@@ -39,8 +40,14 @@ func New(ctx context.Context, options option.Options) (*Box, error) {
 	logOptions := common.PtrValueOrDefault(options.Log)
 
 	var needClashAPI bool
-	if options.Experimental != nil && options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.ExternalController != "" {
-		needClashAPI = true
+	var needV2RayAPI bool
+	if options.Experimental != nil {
+		if options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.ExternalController != "" {
+			needClashAPI = true
+		}
+		if options.Experimental.V2RayAPI != nil && options.Experimental.V2RayAPI.Listen != "" {
+			needV2RayAPI = true
+		}
 	}
 
 	var logFactory log.Factory
@@ -149,6 +156,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) {
 	}
 
 	var clashServer adapter.ClashServer
+	var v2rayServer adapter.V2RayServer
 	if needClashAPI {
 		clashServer, err = experimental.NewClashServer(router, observableLogFactory, common.PtrValueOrDefault(options.Experimental.ClashAPI))
 		if err != nil {
@@ -156,6 +164,13 @@ func New(ctx context.Context, options option.Options) (*Box, error) {
 		}
 		router.SetClashServer(clashServer)
 	}
+	if needV2RayAPI {
+		v2rayServer, err = experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), common.PtrValueOrDefault(options.Experimental.V2RayAPI))
+		if err != nil {
+			return nil, E.Cause(err, "create v2ray api server")
+		}
+		router.SetV2RayServer(v2rayServer)
+	}
 	return &Box{
 		router:      router,
 		inbounds:    inbounds,
@@ -165,6 +180,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) {
 		logger:      logFactory.NewLogger(""),
 		logFile:     logFile,
 		clashServer: clashServer,
+		v2rayServer: v2rayServer,
 		done:        make(chan struct{}),
 	}, nil
 }
@@ -223,6 +239,12 @@ func (s *Box) start() error {
 			return E.Cause(err, "start clash api server")
 		}
 	}
+	if s.v2rayServer != nil {
+		err = s.v2rayServer.Start()
+		if err != nil {
+			return E.Cause(err, "start v2ray api server")
+		}
+	}
 	s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)")
 	return nil
 }
@@ -244,6 +266,11 @@ func (s *Box) Close() error {
 		s.router,
 		s.logFactory,
 		s.clashServer,
+		s.v2rayServer,
 		common.PtrOrNil(s.logFile),
 	)
 }
+
+func (s *Box) Router() adapter.Router {
+	return s.router
+}

+ 2 - 0
constant/err.go

@@ -3,3 +3,5 @@ package constant
 import E "github.com/sagernet/sing/common/exceptions"
 
 var ErrTLSRequired = E.New("TLS required")
+
+var ErrQUICNotIncluded = E.New(`QUIC is not included in this build, rebuild with -tags with_quic`)

+ 14 - 4
experimental/clashapi.go

@@ -1,14 +1,24 @@
-//go:build with_clash_api
-
 package experimental
 
 import (
+	"os"
+
 	"github.com/sagernet/sing-box/adapter"
-	"github.com/sagernet/sing-box/experimental/clashapi"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 )
 
+type ClashServerConstructor = func(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error)
+
+var clashServerConstructor ClashServerConstructor
+
+func RegisterClashServerConstructor(constructor ClashServerConstructor) {
+	clashServerConstructor = constructor
+}
+
 func NewClashServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
-	return clashapi.NewServer(router, logFactory, options)
+	if clashServerConstructor == nil {
+		return nil, os.ErrInvalid
+	}
+	return clashServerConstructor(router, logFactory, options)
 }

+ 7 - 0
experimental/clashapi/server.go

@@ -14,6 +14,7 @@ import (
 	"github.com/sagernet/sing-box/common/json"
 	"github.com/sagernet/sing-box/common/urltest"
 	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/experimental"
 	"github.com/sagernet/sing-box/experimental/clashapi/cachefile"
 	"github.com/sagernet/sing-box/experimental/clashapi/trafficontrol"
 	"github.com/sagernet/sing-box/log"
@@ -29,6 +30,12 @@ import (
 	"github.com/go-chi/render"
 )
 
+func init() {
+	experimental.RegisterClashServerConstructor(func(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
+		return NewServer(router, logFactory, options)
+	})
+}
+
 var _ adapter.ClashServer = (*Server)(nil)
 
 type Server struct {

+ 0 - 14
experimental/clashapi_stub.go

@@ -1,14 +0,0 @@
-//go:build !with_clash_api
-
-package experimental
-
-import (
-	"github.com/sagernet/sing-box/adapter"
-	"github.com/sagernet/sing-box/log"
-	"github.com/sagernet/sing-box/option"
-	E "github.com/sagernet/sing/common/exceptions"
-)
-
-func NewClashServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
-	return nil, E.New(`clash api is not included in this build, rebuild with -tags with_clash_api`)
-}

+ 4 - 0
experimental/trackerconn/conn.go

@@ -57,6 +57,10 @@ func (c *Conn) WriteBuffer(buffer *buf.Buffer) error {
 	return nil
 }
 
+func (c *Conn) Upstream() any {
+	return c.ExtendedConn
+}
+
 type DirectConn Conn
 
 func (c *DirectConn) WriteTo(w io.Writer) (n int64, err error) {

+ 4 - 0
experimental/trackerconn/packet_conn.go

@@ -35,3 +35,7 @@ func (c *PacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) er
 	c.writeCounter.Add(dataLen)
 	return nil
 }
+
+func (c *PacketConn) Upstream() any {
+	return c.PacketConn
+}

+ 24 - 0
experimental/v2rayapi.go

@@ -0,0 +1,24 @@
+package experimental
+
+import (
+	"os"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+)
+
+type V2RayServerConstructor = func(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error)
+
+var v2rayServerConstructor V2RayServerConstructor
+
+func RegisterV2RayServerConstructor(constructor V2RayServerConstructor) {
+	v2rayServerConstructor = constructor
+}
+
+func NewV2RayServer(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) {
+	if v2rayServerConstructor == nil {
+		return nil, os.ErrInvalid
+	}
+	return v2rayServerConstructor(logger, options)
+}

+ 75 - 0
experimental/v2rayapi/server.go

@@ -0,0 +1,75 @@
+package v2rayapi
+
+import (
+	"errors"
+	"net"
+	"net/http"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+)
+
+func init() {
+	experimental.RegisterV2RayServerConstructor(NewServer)
+}
+
+var _ adapter.V2RayServer = (*Server)(nil)
+
+type Server struct {
+	logger       log.Logger
+	listen       string
+	tcpListener  net.Listener
+	grpcServer   *grpc.Server
+	statsService *StatsService
+}
+
+func NewServer(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) {
+	grpcServer := grpc.NewServer(grpc.Creds(insecure.NewCredentials()))
+	statsService := NewStatsService(common.PtrValueOrDefault(options.Stats))
+	if statsService != nil {
+		RegisterStatsServiceServer(grpcServer, statsService)
+	}
+	server := &Server{
+		logger:       logger,
+		listen:       options.Listen,
+		grpcServer:   grpcServer,
+		statsService: statsService,
+	}
+	return server, nil
+}
+
+func (s *Server) Start() error {
+	listener, err := net.Listen("tcp", s.listen)
+	if err != nil {
+		return err
+	}
+	s.logger.Info("grpc server started at ", listener.Addr())
+	s.tcpListener = listener
+	go func() {
+		err = s.grpcServer.Serve(listener)
+		if err != nil && !errors.Is(err, http.ErrServerClosed) {
+			s.logger.Error(err)
+		}
+	}()
+	return nil
+}
+
+func (s *Server) Close() error {
+	if s.grpcServer != nil {
+		s.grpcServer.Stop()
+	}
+	return common.Close(
+		common.PtrOrNil(s.grpcServer),
+		s.tcpListener,
+	)
+}
+
+func (s *Server) StatsService() adapter.V2RayStatsService {
+	return s.statsService
+}

+ 193 - 0
experimental/v2rayapi/stats.go

@@ -0,0 +1,193 @@
+package v2rayapi
+
+import (
+	"context"
+	"net"
+	"regexp"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental/trackerconn"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+	N "github.com/sagernet/sing/common/network"
+
+	"go.uber.org/atomic"
+)
+
+func init() {
+	StatsService_ServiceDesc.ServiceName = "v2ray.core.app.stats.command.StatsService"
+}
+
+var (
+	_ adapter.V2RayStatsService = (*StatsService)(nil)
+	_ StatsServiceServer        = (*StatsService)(nil)
+)
+
+type StatsService struct {
+	createdAt time.Time
+	directIO  bool
+	inbounds  map[string]bool
+	outbounds map[string]bool
+	access    sync.Mutex
+	counters  map[string]*atomic.Int64
+}
+
+func NewStatsService(options option.V2RayStatsServiceOptions) *StatsService {
+	if !options.Enabled {
+		return nil
+	}
+	inbounds := make(map[string]bool)
+	outbounds := make(map[string]bool)
+	for _, inbound := range options.Inbounds {
+		inbounds[inbound] = true
+	}
+	for _, outbound := range options.Outbounds {
+		outbounds[outbound] = true
+	}
+	return &StatsService{
+		createdAt: time.Now(),
+		directIO:  options.DirectIO,
+		inbounds:  inbounds,
+		outbounds: outbounds,
+		counters:  make(map[string]*atomic.Int64),
+	}
+}
+
+func (s *StatsService) RoutedConnection(inbound string, outbound string, conn net.Conn) net.Conn {
+	var readCounter *atomic.Int64
+	var writeCounter *atomic.Int64
+	countInbound := inbound != "" && s.inbounds[inbound]
+	countOutbound := outbound != "" && s.outbounds[outbound]
+	if !countInbound && !countOutbound {
+		return conn
+	}
+	s.access.Lock()
+	if countInbound {
+		readCounter = s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink", readCounter)
+		writeCounter = s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink", writeCounter)
+	}
+	if countOutbound {
+		readCounter = s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink", readCounter)
+		writeCounter = s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink", writeCounter)
+	}
+	s.access.Unlock()
+	return trackerconn.New(conn, readCounter, writeCounter, s.directIO)
+}
+
+func (s *StatsService) RoutedPacketConnection(inbound string, outbound string, conn N.PacketConn) N.PacketConn {
+	var readCounter *atomic.Int64
+	var writeCounter *atomic.Int64
+	countInbound := inbound != "" && s.inbounds[inbound]
+	countOutbound := outbound != "" && s.outbounds[outbound]
+	if !countInbound && !countOutbound {
+		return conn
+	}
+	s.access.Lock()
+	if countInbound {
+		readCounter = s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink", readCounter)
+		writeCounter = s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink", writeCounter)
+	}
+	if countOutbound {
+		readCounter = s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink", readCounter)
+		writeCounter = s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink", writeCounter)
+	}
+	s.access.Unlock()
+	return trackerconn.NewPacket(conn, readCounter, writeCounter)
+}
+
+func (s *StatsService) GetStats(ctx context.Context, request *GetStatsRequest) (*GetStatsResponse, error) {
+	s.access.Lock()
+	counter, loaded := s.counters[request.Name]
+	s.access.Unlock()
+	if !loaded {
+		return nil, E.New(request.Name, " not found.")
+	}
+	var value int64
+	if request.Reset_ {
+		value = counter.Swap(0)
+	} else {
+		value = counter.Load()
+	}
+	return &GetStatsResponse{Stat: &Stat{Name: request.Name, Value: value}}, nil
+}
+
+func (s *StatsService) QueryStats(ctx context.Context, request *QueryStatsRequest) (*QueryStatsResponse, error) {
+	var response QueryStatsResponse
+	s.access.Lock()
+	defer s.access.Unlock()
+	if request.Regexp {
+		matchers := make([]*regexp.Regexp, 0, len(request.Patterns))
+		for _, pattern := range request.Patterns {
+			matcher, err := regexp.Compile(pattern)
+			if err != nil {
+				return nil, err
+			}
+			matchers = append(matchers, matcher)
+		}
+		for name, counter := range s.counters {
+			for _, matcher := range matchers {
+				if matcher.MatchString(name) {
+					var value int64
+					if request.Reset_ {
+						value = counter.Swap(0)
+					} else {
+						value = counter.Load()
+					}
+					response.Stat = append(response.Stat, &Stat{Name: name, Value: value})
+				}
+			}
+		}
+	} else {
+		for name, counter := range s.counters {
+			for _, matcher := range request.Patterns {
+				if strings.Contains(name, matcher) {
+					var value int64
+					if request.Reset_ {
+						value = counter.Swap(0)
+					} else {
+						value = counter.Load()
+					}
+					response.Stat = append(response.Stat, &Stat{Name: name, Value: value})
+				}
+			}
+		}
+	}
+	return &response, nil
+}
+
+func (s *StatsService) GetSysStats(ctx context.Context, request *SysStatsRequest) (*SysStatsResponse, error) {
+	var rtm runtime.MemStats
+	runtime.ReadMemStats(&rtm)
+	response := &SysStatsResponse{
+		Uptime:       uint32(time.Now().Sub(s.createdAt).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,
+	}
+
+	return response, nil
+}
+
+func (s *StatsService) mustEmbedUnimplementedStatsServiceServer() {
+}
+
+func (s *StatsService) loadOrCreateCounter(name string, counter *atomic.Int64) *atomic.Int64 {
+	counter, loaded := s.counters[name]
+	if !loaded {
+		if counter == nil {
+			counter = atomic.NewInt64(0)
+		}
+		s.counters[name] = counter
+	}
+	return counter
+}

+ 678 - 0
experimental/v2rayapi/stats.pb.go

@@ -0,0 +1,678 @@
+package v2rayapi
+
+import (
+	reflect "reflect"
+	sync "sync"
+
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type GetStatsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Name of the stat counter.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Whether or not to reset the counter to fetching its value.
+	Reset_ bool `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"`
+}
+
+func (x *GetStatsRequest) Reset() {
+	*x = GetStatsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetStatsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStatsRequest) ProtoMessage() {}
+
+func (x *GetStatsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead.
+func (*GetStatsRequest) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetStatsRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *GetStatsRequest) GetReset_() bool {
+	if x != nil {
+		return x.Reset_
+	}
+	return false
+}
+
+type Stat struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name  string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	Value int64  `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
+}
+
+func (x *Stat) Reset() {
+	*x = Stat{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Stat) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Stat) ProtoMessage() {}
+
+func (x *Stat) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Stat.ProtoReflect.Descriptor instead.
+func (*Stat) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Stat) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Stat) GetValue() int64 {
+	if x != nil {
+		return x.Value
+	}
+	return 0
+}
+
+type GetStatsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Stat *Stat `protobuf:"bytes,1,opt,name=stat,proto3" json:"stat,omitempty"`
+}
+
+func (x *GetStatsResponse) Reset() {
+	*x = GetStatsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetStatsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStatsResponse) ProtoMessage() {}
+
+func (x *GetStatsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead.
+func (*GetStatsResponse) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GetStatsResponse) GetStat() *Stat {
+	if x != nil {
+		return x.Stat
+	}
+	return nil
+}
+
+type QueryStatsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Deprecated, use Patterns instead
+	Pattern  string   `protobuf:"bytes,1,opt,name=pattern,proto3" json:"pattern,omitempty"`
+	Reset_   bool     `protobuf:"varint,2,opt,name=reset,proto3" json:"reset,omitempty"`
+	Patterns []string `protobuf:"bytes,3,rep,name=patterns,proto3" json:"patterns,omitempty"`
+	Regexp   bool     `protobuf:"varint,4,opt,name=regexp,proto3" json:"regexp,omitempty"`
+}
+
+func (x *QueryStatsRequest) Reset() {
+	*x = QueryStatsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryStatsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryStatsRequest) ProtoMessage() {}
+
+func (x *QueryStatsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryStatsRequest.ProtoReflect.Descriptor instead.
+func (*QueryStatsRequest) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *QueryStatsRequest) GetPattern() string {
+	if x != nil {
+		return x.Pattern
+	}
+	return ""
+}
+
+func (x *QueryStatsRequest) GetReset_() bool {
+	if x != nil {
+		return x.Reset_
+	}
+	return false
+}
+
+func (x *QueryStatsRequest) GetPatterns() []string {
+	if x != nil {
+		return x.Patterns
+	}
+	return nil
+}
+
+func (x *QueryStatsRequest) GetRegexp() bool {
+	if x != nil {
+		return x.Regexp
+	}
+	return false
+}
+
+type QueryStatsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Stat []*Stat `protobuf:"bytes,1,rep,name=stat,proto3" json:"stat,omitempty"`
+}
+
+func (x *QueryStatsResponse) Reset() {
+	*x = QueryStatsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryStatsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryStatsResponse) ProtoMessage() {}
+
+func (x *QueryStatsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryStatsResponse.ProtoReflect.Descriptor instead.
+func (*QueryStatsResponse) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *QueryStatsResponse) GetStat() []*Stat {
+	if x != nil {
+		return x.Stat
+	}
+	return nil
+}
+
+type SysStatsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *SysStatsRequest) Reset() {
+	*x = SysStatsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SysStatsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SysStatsRequest) ProtoMessage() {}
+
+func (x *SysStatsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SysStatsRequest.ProtoReflect.Descriptor instead.
+func (*SysStatsRequest) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{5}
+}
+
+type SysStatsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	NumGoroutine uint32 `protobuf:"varint,1,opt,name=NumGoroutine,proto3" json:"NumGoroutine,omitempty"`
+	NumGC        uint32 `protobuf:"varint,2,opt,name=NumGC,proto3" json:"NumGC,omitempty"`
+	Alloc        uint64 `protobuf:"varint,3,opt,name=Alloc,proto3" json:"Alloc,omitempty"`
+	TotalAlloc   uint64 `protobuf:"varint,4,opt,name=TotalAlloc,proto3" json:"TotalAlloc,omitempty"`
+	Sys          uint64 `protobuf:"varint,5,opt,name=Sys,proto3" json:"Sys,omitempty"`
+	Mallocs      uint64 `protobuf:"varint,6,opt,name=Mallocs,proto3" json:"Mallocs,omitempty"`
+	Frees        uint64 `protobuf:"varint,7,opt,name=Frees,proto3" json:"Frees,omitempty"`
+	LiveObjects  uint64 `protobuf:"varint,8,opt,name=LiveObjects,proto3" json:"LiveObjects,omitempty"`
+	PauseTotalNs uint64 `protobuf:"varint,9,opt,name=PauseTotalNs,proto3" json:"PauseTotalNs,omitempty"`
+	Uptime       uint32 `protobuf:"varint,10,opt,name=Uptime,proto3" json:"Uptime,omitempty"`
+}
+
+func (x *SysStatsResponse) Reset() {
+	*x = SysStatsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_experimental_v2rayapi_stats_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SysStatsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SysStatsResponse) ProtoMessage() {}
+
+func (x *SysStatsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_experimental_v2rayapi_stats_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SysStatsResponse.ProtoReflect.Descriptor instead.
+func (*SysStatsResponse) Descriptor() ([]byte, []int) {
+	return file_experimental_v2rayapi_stats_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *SysStatsResponse) GetNumGoroutine() uint32 {
+	if x != nil {
+		return x.NumGoroutine
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetNumGC() uint32 {
+	if x != nil {
+		return x.NumGC
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetAlloc() uint64 {
+	if x != nil {
+		return x.Alloc
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetTotalAlloc() uint64 {
+	if x != nil {
+		return x.TotalAlloc
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetSys() uint64 {
+	if x != nil {
+		return x.Sys
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetMallocs() uint64 {
+	if x != nil {
+		return x.Mallocs
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetFrees() uint64 {
+	if x != nil {
+		return x.Frees
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetLiveObjects() uint64 {
+	if x != nil {
+		return x.LiveObjects
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetPauseTotalNs() uint64 {
+	if x != nil {
+		return x.PauseTotalNs
+	}
+	return 0
+}
+
+func (x *SysStatsResponse) GetUptime() uint32 {
+	if x != nil {
+		return x.Uptime
+	}
+	return 0
+}
+
+var File_experimental_v2rayapi_stats_proto protoreflect.FileDescriptor
+
+var file_experimental_v2rayapi_stats_proto_rawDesc = []byte{
+	0x0a, 0x21, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2f, 0x76,
+	0x32, 0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x73, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x12, 0x15, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61,
+	0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x22, 0x3b, 0x0a, 0x0f, 0x47, 0x65,
+	0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x05, 0x72, 0x65, 0x73, 0x65, 0x74, 0x22, 0x30, 0x0a, 0x04, 0x53, 0x74, 0x61, 0x74, 0x12,
+	0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
+	0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x43, 0x0a, 0x10, 0x47, 0x65, 0x74,
+	0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a,
+	0x04, 0x73, 0x74, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x65, 0x78,
+	0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79,
+	0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x52, 0x04, 0x73, 0x74, 0x61, 0x74, 0x22, 0x77,
+	0x0a, 0x11, 0x51, 0x75, 0x65, 0x72, 0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x12, 0x14, 0x0a,
+	0x05, 0x72, 0x65, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65,
+	0x73, 0x65, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x73, 0x18,
+	0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x73, 0x12,
+	0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x65, 0x78, 0x70, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x06, 0x72, 0x65, 0x67, 0x65, 0x78, 0x70, 0x22, 0x45, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72, 0x79,
+	0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a,
+	0x04, 0x73, 0x74, 0x61, 0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x65, 0x78,
+	0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79,
+	0x61, 0x70, 0x69, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x52, 0x04, 0x73, 0x74, 0x61, 0x74, 0x22, 0x11,
+	0x0a, 0x0f, 0x53, 0x79, 0x73, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x22, 0xa2, 0x02, 0x0a, 0x10, 0x53, 0x79, 0x73, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x4e, 0x75, 0x6d, 0x47, 0x6f, 0x72,
+	0x6f, 0x75, 0x74, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x4e, 0x75,
+	0x6d, 0x47, 0x6f, 0x72, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x75,
+	0x6d, 0x47, 0x43, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x4e, 0x75, 0x6d, 0x47, 0x43,
+	0x12, 0x14, 0x0a, 0x05, 0x41, 0x6c, 0x6c, 0x6f, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52,
+	0x05, 0x41, 0x6c, 0x6c, 0x6f, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x41,
+	0x6c, 0x6c, 0x6f, 0x63, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x54, 0x6f, 0x74, 0x61,
+	0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x63, 0x12, 0x10, 0x0a, 0x03, 0x53, 0x79, 0x73, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x04, 0x52, 0x03, 0x53, 0x79, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x61, 0x6c, 0x6c,
+	0x6f, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x4d, 0x61, 0x6c, 0x6c, 0x6f,
+	0x63, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x72, 0x65, 0x65, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28,
+	0x04, 0x52, 0x05, 0x46, 0x72, 0x65, 0x65, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x4c, 0x69, 0x76, 0x65,
+	0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x4c,
+	0x69, 0x76, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x50, 0x61,
+	0x75, 0x73, 0x65, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x4e, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04,
+	0x52, 0x0c, 0x50, 0x61, 0x75, 0x73, 0x65, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x4e, 0x73, 0x12, 0x16,
+	0x0a, 0x06, 0x55, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06,
+	0x55, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x32, 0xb4, 0x02, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x74, 0x73,
+	0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5d, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74,
+	0x61, 0x74, 0x73, 0x12, 0x26, 0x2e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74,
+	0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x53,
+	0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x65, 0x78,
+	0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79,
+	0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x63, 0x0a, 0x0a, 0x51, 0x75, 0x65, 0x72, 0x79, 0x53,
+	0x74, 0x61, 0x74, 0x73, 0x12, 0x28, 0x2e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e,
+	0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x51, 0x75, 0x65,
+	0x72, 0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29,
+	0x2e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32,
+	0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x53, 0x74, 0x61, 0x74,
+	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x60, 0x0a, 0x0b, 0x47,
+	0x65, 0x74, 0x53, 0x79, 0x73, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x26, 0x2e, 0x65, 0x78, 0x70,
+	0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x61,
+	0x70, 0x69, 0x2e, 0x53, 0x79, 0x73, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x27, 0x2e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61,
+	0x6c, 0x2e, 0x76, 0x32, 0x72, 0x61, 0x79, 0x61, 0x70, 0x69, 0x2e, 0x53, 0x79, 0x73, 0x53, 0x74,
+	0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x34, 0x5a,
+	0x32, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x61, 0x67, 0x65,
+	0x72, 0x6e, 0x65, 0x74, 0x2f, 0x73, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x78, 0x2f, 0x65, 0x78,
+	0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x2f, 0x76, 0x32, 0x72, 0x61, 0x79,
+	0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_experimental_v2rayapi_stats_proto_rawDescOnce sync.Once
+	file_experimental_v2rayapi_stats_proto_rawDescData = file_experimental_v2rayapi_stats_proto_rawDesc
+)
+
+func file_experimental_v2rayapi_stats_proto_rawDescGZIP() []byte {
+	file_experimental_v2rayapi_stats_proto_rawDescOnce.Do(func() {
+		file_experimental_v2rayapi_stats_proto_rawDescData = protoimpl.X.CompressGZIP(file_experimental_v2rayapi_stats_proto_rawDescData)
+	})
+	return file_experimental_v2rayapi_stats_proto_rawDescData
+}
+
+var (
+	file_experimental_v2rayapi_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
+	file_experimental_v2rayapi_stats_proto_goTypes  = []interface{}{
+		(*GetStatsRequest)(nil),    // 0: experimental.v2rayapi.GetStatsRequest
+		(*Stat)(nil),               // 1: experimental.v2rayapi.Stat
+		(*GetStatsResponse)(nil),   // 2: experimental.v2rayapi.GetStatsResponse
+		(*QueryStatsRequest)(nil),  // 3: experimental.v2rayapi.QueryStatsRequest
+		(*QueryStatsResponse)(nil), // 4: experimental.v2rayapi.QueryStatsResponse
+		(*SysStatsRequest)(nil),    // 5: experimental.v2rayapi.SysStatsRequest
+		(*SysStatsResponse)(nil),   // 6: experimental.v2rayapi.SysStatsResponse
+	}
+)
+
+var file_experimental_v2rayapi_stats_proto_depIdxs = []int32{
+	1, // 0: experimental.v2rayapi.GetStatsResponse.stat:type_name -> experimental.v2rayapi.Stat
+	1, // 1: experimental.v2rayapi.QueryStatsResponse.stat:type_name -> experimental.v2rayapi.Stat
+	0, // 2: experimental.v2rayapi.StatsService.GetStats:input_type -> experimental.v2rayapi.GetStatsRequest
+	3, // 3: experimental.v2rayapi.StatsService.QueryStats:input_type -> experimental.v2rayapi.QueryStatsRequest
+	5, // 4: experimental.v2rayapi.StatsService.GetSysStats:input_type -> experimental.v2rayapi.SysStatsRequest
+	2, // 5: experimental.v2rayapi.StatsService.GetStats:output_type -> experimental.v2rayapi.GetStatsResponse
+	4, // 6: experimental.v2rayapi.StatsService.QueryStats:output_type -> experimental.v2rayapi.QueryStatsResponse
+	6, // 7: experimental.v2rayapi.StatsService.GetSysStats:output_type -> experimental.v2rayapi.SysStatsResponse
+	5, // [5:8] is the sub-list for method output_type
+	2, // [2:5] is the sub-list for method input_type
+	2, // [2:2] is the sub-list for extension type_name
+	2, // [2:2] is the sub-list for extension extendee
+	0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_experimental_v2rayapi_stats_proto_init() }
+func file_experimental_v2rayapi_stats_proto_init() {
+	if File_experimental_v2rayapi_stats_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_experimental_v2rayapi_stats_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetStatsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Stat); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetStatsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryStatsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryStatsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SysStatsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_experimental_v2rayapi_stats_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SysStatsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_experimental_v2rayapi_stats_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   7,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_experimental_v2rayapi_stats_proto_goTypes,
+		DependencyIndexes: file_experimental_v2rayapi_stats_proto_depIdxs,
+		MessageInfos:      file_experimental_v2rayapi_stats_proto_msgTypes,
+	}.Build()
+	File_experimental_v2rayapi_stats_proto = out.File
+	file_experimental_v2rayapi_stats_proto_rawDesc = nil
+	file_experimental_v2rayapi_stats_proto_goTypes = nil
+	file_experimental_v2rayapi_stats_proto_depIdxs = nil
+}

+ 53 - 0
experimental/v2rayapi/stats.proto

@@ -0,0 +1,53 @@
+syntax = "proto3";
+
+package experimental.v2rayapi;
+option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi";
+
+message GetStatsRequest {
+  // Name of the stat counter.
+  string name = 1;
+  // Whether or not to reset the counter to fetching its value.
+  bool reset = 2;
+}
+
+message Stat {
+  string name = 1;
+  int64 value = 2;
+}
+
+message GetStatsResponse {
+  Stat stat = 1;
+}
+
+message QueryStatsRequest {
+  // Deprecated, use Patterns instead
+  string pattern = 1;
+  bool reset = 2;
+  repeated string patterns = 3;
+  bool regexp = 4;
+}
+
+message QueryStatsResponse {
+  repeated Stat stat = 1;
+}
+
+message SysStatsRequest {}
+
+message SysStatsResponse {
+  uint32 NumGoroutine = 1;
+  uint32 NumGC = 2;
+  uint64 Alloc = 3;
+  uint64 TotalAlloc = 4;
+  uint64 Sys = 5;
+  uint64 Mallocs = 6;
+  uint64 Frees = 7;
+  uint64 LiveObjects = 8;
+  uint64 PauseTotalNs = 9;
+  uint32 Uptime = 10;
+}
+
+service StatsService {
+  rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {}
+  rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
+  rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {}
+}

+ 173 - 0
experimental/v2rayapi/stats_grpc.pb.go

@@ -0,0 +1,173 @@
+package v2rayapi
+
+import (
+	context "context"
+
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// StatsServiceClient is the client API for StatsService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type StatsServiceClient interface {
+	GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
+	QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error)
+	GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error)
+}
+
+type statsServiceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewStatsServiceClient(cc grpc.ClientConnInterface) StatsServiceClient {
+	return &statsServiceClient{cc}
+}
+
+func (c *statsServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
+	out := new(GetStatsResponse)
+	err := c.cc.Invoke(ctx, "/experimental.v2rayapi.StatsService/GetStats", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *statsServiceClient) QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error) {
+	out := new(QueryStatsResponse)
+	err := c.cc.Invoke(ctx, "/experimental.v2rayapi.StatsService/QueryStats", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *statsServiceClient) GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error) {
+	out := new(SysStatsResponse)
+	err := c.cc.Invoke(ctx, "/experimental.v2rayapi.StatsService/GetSysStats", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// StatsServiceServer is the server API for StatsService service.
+// All implementations must embed UnimplementedStatsServiceServer
+// for forward compatibility
+type StatsServiceServer interface {
+	GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
+	QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error)
+	GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error)
+	mustEmbedUnimplementedStatsServiceServer()
+}
+
+// UnimplementedStatsServiceServer must be embedded to have forward compatible implementations.
+type UnimplementedStatsServiceServer struct{}
+
+func (UnimplementedStatsServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented")
+}
+
+func (UnimplementedStatsServiceServer) QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryStats not implemented")
+}
+
+func (UnimplementedStatsServiceServer) GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetSysStats not implemented")
+}
+func (UnimplementedStatsServiceServer) mustEmbedUnimplementedStatsServiceServer() {}
+
+// UnsafeStatsServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to StatsServiceServer will
+// result in compilation errors.
+type UnsafeStatsServiceServer interface {
+	mustEmbedUnimplementedStatsServiceServer()
+}
+
+func RegisterStatsServiceServer(s grpc.ServiceRegistrar, srv StatsServiceServer) {
+	s.RegisterService(&StatsService_ServiceDesc, srv)
+}
+
+func _StatsService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetStatsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(StatsServiceServer).GetStats(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/experimental.v2rayapi.StatsService/GetStats",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(StatsServiceServer).GetStats(ctx, req.(*GetStatsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _StatsService_QueryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryStatsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(StatsServiceServer).QueryStats(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/experimental.v2rayapi.StatsService/QueryStats",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(StatsServiceServer).QueryStats(ctx, req.(*QueryStatsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _StatsService_GetSysStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(SysStatsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(StatsServiceServer).GetSysStats(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/experimental.v2rayapi.StatsService/GetSysStats",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(StatsServiceServer).GetSysStats(ctx, req.(*SysStatsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// StatsService_ServiceDesc is the grpc.ServiceDesc for StatsService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var StatsService_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "experimental.v2rayapi.StatsService",
+	HandlerType: (*StatsServiceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetStats",
+			Handler:    _StatsService_GetStats_Handler,
+		},
+		{
+			MethodName: "QueryStats",
+			Handler:    _StatsService_QueryStats_Handler,
+		},
+		{
+			MethodName: "GetSysStats",
+			Handler:    _StatsService_GetSysStats_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "experimental/v2rayapi/stats.proto",
+}

+ 2 - 2
inbound/hysteria_stub.go

@@ -6,11 +6,11 @@ import (
 	"context"
 
 	"github.com/sagernet/sing-box/adapter"
-	I "github.com/sagernet/sing-box/include"
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 )
 
 func NewHysteria(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaInboundOptions) (adapter.Inbound, error) {
-	return nil, I.ErrQUICNotIncluded
+	return nil, C.ErrQUICNotIncluded
 }

+ 2 - 2
inbound/naive_quic_stub.go

@@ -3,9 +3,9 @@
 package inbound
 
 import (
-	I "github.com/sagernet/sing-box/include"
+	C "github.com/sagernet/sing-box/constant"
 )
 
 func (n *Naive) configureHTTP3Listener() error {
-	return I.ErrQUICNotIncluded
+	return C.ErrQUICNotIncluded
 }

+ 5 - 0
include/clashapi.go

@@ -0,0 +1,5 @@
+//go:build with_clash_api
+
+package include
+
+import _ "github.com/sagernet/sing-box/experimental/clashapi"

+ 17 - 0
include/clashapi_stub.go

@@ -0,0 +1,17 @@
+//go:build !with_clash_api
+
+package include
+
+import (
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+func init() {
+	experimental.RegisterClashServerConstructor(func(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
+		return nil, E.New(`clash api is not included in this build, rebuild with -tags with_clash_api`)
+	})
+}

+ 4 - 1
include/quic.go

@@ -2,6 +2,9 @@
 
 package include
 
-import _ "github.com/sagernet/sing-dns/quic"
+import (
+	_ "github.com/sagernet/sing-box/transport/v2rayquic"
+	_ "github.com/sagernet/sing-dns/quic"
+)
 
 const WithQUIC = true

+ 15 - 3
include/quic_stub.go

@@ -5,17 +5,29 @@ package include
 import (
 	"context"
 
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/tls"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing-box/transport/v2ray"
 	"github.com/sagernet/sing-dns"
 	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 )
 
 const WithQUIC = false
 
-var ErrQUICNotIncluded = E.New(`QUIC is not included in this build, rebuild with -tags with_quic`)
-
 func init() {
 	dns.RegisterTransport([]string{"quic", "h3"}, func(ctx context.Context, dialer N.Dialer, link string) (dns.Transport, error) {
-		return nil, ErrQUICNotIncluded
+		return nil, C.ErrQUICNotIncluded
 	})
+	v2ray.RegisterQUICConstructor(
+		func(ctx context.Context, options option.V2RayQUICOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error) {
+			return nil, C.ErrQUICNotIncluded
+		},
+		func(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) {
+			return nil, C.ErrQUICNotIncluded
+		},
+	)
 }

+ 5 - 0
include/v2rayapi.go

@@ -0,0 +1,5 @@
+//go:build with_v2ray_api
+
+package include
+
+import _ "github.com/sagernet/sing-box/experimental/v2rayapi"

+ 17 - 0
include/v2rayapi_stub.go

@@ -0,0 +1,17 @@
+//go:build !with_v2ray_api
+
+package include
+
+import (
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+func init() {
+	experimental.RegisterV2RayServerConstructor(func(logger log.Logger, options option.V2RayAPIOptions) (adapter.V2RayServer, error) {
+		return nil, E.New(`v2ray api is not included in this build, rebuild with -tags with_v2ray_api`)
+	})
+}

+ 1 - 0
option/experimental.go

@@ -2,4 +2,5 @@ package option
 
 type ExperimentalOptions struct {
 	ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"`
+	V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"`
 }

+ 13 - 0
option/v2ray.go

@@ -0,0 +1,13 @@
+package option
+
+type V2RayAPIOptions struct {
+	Listen string                    `json:"listen,omitempty"`
+	Stats  *V2RayStatsServiceOptions `json:"stats,omitempty"`
+}
+
+type V2RayStatsServiceOptions struct {
+	Enabled   bool     `json:"enabled,omitempty"`
+	DirectIO  bool     `json:"direct_io,omitempty"`
+	Inbounds  []string `json:"inbounds,omitempty"`
+	Outbounds []string `json:"outbounds,omitempty"`
+}

+ 2 - 2
outbound/hysteria_stub.go

@@ -6,11 +6,11 @@ import (
 	"context"
 
 	"github.com/sagernet/sing-box/adapter"
-	I "github.com/sagernet/sing-box/include"
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 )
 
 func NewHysteria(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HysteriaOutboundOptions) (adapter.Outbound, error) {
-	return nil, I.ErrQUICNotIncluded
+	return nil, C.ErrQUICNotIncluded
 }

+ 22 - 3
route/router.go

@@ -93,8 +93,9 @@ type Router struct {
 	networkMonitor                     tun.NetworkUpdateMonitor
 	interfaceMonitor                   tun.DefaultInterfaceMonitor
 	packageManager                     tun.PackageManager
-	clashServer                        adapter.ClashServer
 	processSearcher                    process.Searcher
+	clashServer                        adapter.ClashServer
+	v2rayServer                        adapter.V2RayServer
 }
 
 func NewRouter(ctx context.Context, logger log.ContextLogger, dnsLogger log.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions, inbounds []option.Inbound) (*Router, error) {
@@ -590,6 +591,11 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 		defer tracker.Leave()
 		conn = trackerConn
 	}
+	if r.v2rayServer != nil {
+		if statsService := r.v2rayServer.StatsService(); statsService != nil {
+			conn = statsService.RoutedConnection(metadata.Inbound, detour.Tag(), conn)
+		}
+	}
 	return detour.NewConnection(ctx, conn, metadata)
 }
 
@@ -663,6 +669,11 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m
 		defer tracker.Leave()
 		conn = trackerConn
 	}
+	if r.v2rayServer != nil {
+		if statsService := r.v2rayServer.StatsService(); statsService != nil {
+			conn = statsService.RoutedPacketConnection(metadata.Inbound, detour.Tag(), conn)
+		}
+	}
 	return detour.NewPacketConnection(ctx, conn, metadata)
 }
 
@@ -747,8 +758,16 @@ func (r *Router) ClashServer() adapter.ClashServer {
 	return r.clashServer
 }
 
-func (r *Router) SetClashServer(controller adapter.ClashServer) {
-	r.clashServer = controller
+func (r *Router) SetClashServer(server adapter.ClashServer) {
+	r.clashServer = server
+}
+
+func (r *Router) V2RayServer() adapter.V2RayServer {
+	return r.v2rayServer
+}
+
+func (r *Router) SetV2RayServer(server adapter.V2RayServer) {
+	r.v2rayServer = server
 }
 
 func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool {

+ 19 - 5
transport/v2ray/quic.go

@@ -1,23 +1,37 @@
-//go:build with_quic
-
 package v2ray
 
 import (
 	"context"
+	"os"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/tls"
 	"github.com/sagernet/sing-box/option"
-	"github.com/sagernet/sing-box/transport/v2rayquic"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 )
 
+var (
+	quicServerConstructor ServerConstructor[option.V2RayQUICOptions]
+	quicClientConstructor ClientConstructor[option.V2RayQUICOptions]
+)
+
+func RegisterQUICConstructor(server ServerConstructor[option.V2RayQUICOptions], client ClientConstructor[option.V2RayQUICOptions]) {
+	quicServerConstructor = server
+	quicClientConstructor = client
+}
+
 func NewQUICServer(ctx context.Context, options option.V2RayQUICOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error) {
-	return v2rayquic.NewServer(ctx, options, tlsConfig, handler, errorHandler)
+	if quicServerConstructor == nil {
+		return nil, os.ErrInvalid
+	}
+	return quicServerConstructor(ctx, options, tlsConfig, handler, errorHandler)
 }
 
 func NewQUICClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) {
-	return v2rayquic.NewClient(ctx, dialer, serverAddr, options, tlsConfig)
+	if quicClientConstructor == nil {
+		return nil, os.ErrInvalid
+	}
+	return quicClientConstructor(ctx, dialer, serverAddr, options, tlsConfig)
 }

+ 0 - 23
transport/v2ray/quic_stub.go

@@ -1,23 +0,0 @@
-//go:build !with_quic
-
-package v2ray
-
-import (
-	"context"
-
-	"github.com/sagernet/sing-box/adapter"
-	"github.com/sagernet/sing-box/common/tls"
-	I "github.com/sagernet/sing-box/include"
-	"github.com/sagernet/sing-box/option"
-	E "github.com/sagernet/sing/common/exceptions"
-	M "github.com/sagernet/sing/common/metadata"
-	N "github.com/sagernet/sing/common/network"
-)
-
-func NewQUICServer(ctx context.Context, options option.V2RayQUICOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error) {
-	return nil, I.ErrQUICNotIncluded
-}
-
-func NewQUICClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayQUICOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) {
-	return nil, I.ErrQUICNotIncluded
-}

+ 5 - 0
transport/v2ray/transport.go

@@ -14,6 +14,11 @@ import (
 	N "github.com/sagernet/sing/common/network"
 )
 
+type (
+	ServerConstructor[O any] func(ctx context.Context, options O, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error)
+	ClientConstructor[O any] func(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options O, tlsConfig tls.Config) (adapter.V2RayClientTransport, error)
+)
+
 func NewServerTransport(ctx context.Context, options option.V2RayTransportOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error) {
 	if options.Type == "" {
 		return nil, nil

+ 7 - 0
transport/v2rayquic/init.go

@@ -0,0 +1,7 @@
+package v2rayquic
+
+import "github.com/sagernet/sing-box/transport/v2ray"
+
+func init() {
+	v2ray.RegisterQUICConstructor(NewServer, NewClient)
+}

+ 1 - 1
transport/v2rayquic/server.go

@@ -29,7 +29,7 @@ type Server struct {
 	quicListener quic.Listener
 }
 
-func NewServer(ctx context.Context, options option.V2RayQUICOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (*Server, error) {
+func NewServer(ctx context.Context, options option.V2RayQUICOptions, tlsConfig tls.Config, handler N.TCPConnectionHandler, errorHandler E.Handler) (adapter.V2RayServerTransport, error) {
 	quicConfig := &quic.Config{
 		DisablePathMTUDiscovery: !C.IsLinux && !C.IsWindows,
 	}