Browse Source

Least load balancer (#2999)

* v5: Health Check & LeastLoad Strategy (rebased from 2c5a71490368500a982018a74a6d519c7e121816)

Some changes will be necessary to integrate it into V2Ray

* Update proto

* parse duration conf with time.Parse()

* moving health ping to observatory as a standalone component

* moving health ping to observatory as a standalone component: auto generated file

* add initialization for health ping

* incorporate changes in router implementation

* support principle target output

* add v4 json support for BurstObservatory & fix balancer reference

* update API command

* remove cancelled API

* return zero length value when observer is not found

* remove duplicated targeted dispatch

* adjust test with updated structure

* bug fix for observer

* fix strategy selector

* fix strategy least load

* Fix ticker usage

ticker.Close does not close ticker.C

* feat: Replace default Health Ping URL to HTTPS (#1991)

* fix selectLeastLoad() returns wrong number of nodes (#2083)

* Test: fix leastload strategy unit test

* fix(router): panic caused by concurrent map read and write (#2678)

* Clean up code

---------

Co-authored-by: Jebbs <[email protected]>
Co-authored-by: Shelikhoo <[email protected]>
Co-authored-by: 世界 <[email protected]>
Co-authored-by: Bernd Eichelberger <46166740+4-FLOSS-Free-Libre-Open-Source-Software@users.noreply.github.com>
Co-authored-by: 秋のかえで <[email protected]>
Co-authored-by: Rinka <[email protected]>
yuhan6665 1 year ago
parent
commit
fa5d7a255b
100 changed files with 3515 additions and 421 deletions
  1. 1 1
      app/commander/config.pb.go
  2. 1 1
      app/dispatcher/config.pb.go
  3. 1 1
      app/dispatcher/default.go
  4. 1 1
      app/dns/config.pb.go
  5. 1 1
      app/dns/fakedns/fakedns.pb.go
  6. 1 1
      app/log/command/config.pb.go
  7. 1 1
      app/log/config.pb.go
  8. 1 1
      app/metrics/config.pb.go
  9. 14 0
      app/observatory/burst/burst.go
  10. 108 0
      app/observatory/burst/burstobserver.go
  11. 276 0
      app/observatory/burst/config.pb.go
  12. 29 0
      app/observatory/burst/config.proto
  13. 9 0
      app/observatory/burst/errors.generated.go
  14. 244 0
      app/observatory/burst/healthping.go
  15. 143 0
      app/observatory/burst/healthping_result.go
  16. 106 0
      app/observatory/burst/healthping_result_test.go
  17. 69 0
      app/observatory/burst/ping.go
  18. 1 1
      app/observatory/command/command.pb.go
  19. 195 71
      app/observatory/config.pb.go
  20. 11 0
      app/observatory/config.proto
  21. 1 1
      app/policy/config.pb.go
  22. 1 1
      app/proxyman/command/command.pb.go
  23. 1 1
      app/proxyman/config.pb.go
  24. 1 1
      app/reverse/config.pb.go
  25. 68 20
      app/router/balancing.go
  26. 50 0
      app/router/balancing_override.go
  27. 35 0
      app/router/command/command.go
  28. 529 46
      app/router/command/command.pb.go
  29. 31 0
      app/router/command/command.proto
  30. 76 2
      app/router/command/command_grpc.pb.go
  31. 1 1
      app/router/command/command_test.go
  32. 25 8
      app/router/config.go
  33. 401 181
      app/router/config.pb.go
  34. 22 0
      app/router/config.proto
  35. 6 6
      app/router/router.go
  36. 54 5
      app/router/router_test.go
  37. 200 0
      app/router/strategy_leastload.go
  38. 179 0
      app/router/strategy_leastload_test.go
  39. 4 0
      app/router/strategy_leastping.go
  40. 21 0
      app/router/strategy_random.go
  41. 89 0
      app/router/weight.go
  42. 60 0
      app/router/weight_test.go
  43. 1 1
      app/stats/command/command.pb.go
  44. 1 1
      app/stats/config.pb.go
  45. 1 1
      common/log/log.pb.go
  46. 1 1
      common/net/address.pb.go
  47. 1 1
      common/net/destination.pb.go
  48. 1 1
      common/net/network.pb.go
  49. 1 1
      common/net/port.pb.go
  50. 1 1
      common/protocol/headers.pb.go
  51. 1 1
      common/protocol/server_spec.pb.go
  52. 1 1
      common/protocol/user.pb.go
  53. 1 1
      common/serial/typed_message.pb.go
  54. 1 0
      common/session/context.go
  55. 1 1
      core/config.pb.go
  56. 10 0
      features/routing/balancer.go
  57. 3 0
      infra/conf/api.go
  58. 17 1
      infra/conf/observatory.go
  59. 31 13
      infra/conf/router.go
  60. 86 0
      infra/conf/router_strategy.go
  61. 52 0
      infra/conf/router_test.go
  62. 9 0
      infra/conf/xray.go
  63. 2 0
      main/commands/all/api/api.go
  64. 108 0
      main/commands/all/api/balancer_info.go
  65. 77 0
      main/commands/all/api/balancer_override.go
  66. 5 8
      main/commands/all/api/shared.go
  67. 1 1
      proxy/blackhole/config.pb.go
  68. 1 1
      proxy/dns/config.pb.go
  69. 1 1
      proxy/dokodemo/config.pb.go
  70. 1 1
      proxy/freedom/config.pb.go
  71. 1 1
      proxy/http/config.pb.go
  72. 1 1
      proxy/loopback/config.pb.go
  73. 1 1
      proxy/shadowsocks/config.pb.go
  74. 1 1
      proxy/shadowsocks_2022/config.pb.go
  75. 1 1
      proxy/socks/config.pb.go
  76. 1 1
      proxy/trojan/config.pb.go
  77. 1 1
      proxy/vless/account.pb.go
  78. 1 1
      proxy/vless/encoding/addons.pb.go
  79. 1 1
      proxy/vless/inbound/config.pb.go
  80. 1 1
      proxy/vless/outbound/config.pb.go
  81. 1 1
      proxy/vmess/account.pb.go
  82. 1 1
      proxy/vmess/inbound/config.pb.go
  83. 1 1
      proxy/vmess/outbound/config.pb.go
  84. 1 1
      proxy/wireguard/config.pb.go
  85. 1 1
      transport/global/config.pb.go
  86. 1 1
      transport/internet/config.pb.go
  87. 1 1
      transport/internet/domainsocket/config.pb.go
  88. 1 1
      transport/internet/grpc/config.pb.go
  89. 1 1
      transport/internet/grpc/encoding/stream.pb.go
  90. 1 1
      transport/internet/headers/dns/config.pb.go
  91. 1 1
      transport/internet/headers/http/config.pb.go
  92. 1 1
      transport/internet/headers/noop/config.pb.go
  93. 1 1
      transport/internet/headers/srtp/config.pb.go
  94. 1 1
      transport/internet/headers/tls/config.pb.go
  95. 1 1
      transport/internet/headers/utp/config.pb.go
  96. 1 1
      transport/internet/headers/wechat/config.pb.go
  97. 1 1
      transport/internet/headers/wireguard/config.pb.go
  98. 1 1
      transport/internet/http/config.pb.go
  99. 1 1
      transport/internet/kcp/config.pb.go
  100. 1 1
      transport/internet/quic/config.pb.go

+ 1 - 1
app/commander/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/commander/config.proto
 

+ 1 - 1
app/dispatcher/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/dispatcher/config.proto
 

+ 1 - 1
app/dispatcher/default.go

@@ -230,6 +230,7 @@ func (d *DefaultDispatcher) Dispatch(ctx context.Context, destination net.Destin
 		content = new(session.Content)
 		ctx = session.ContextWithContent(ctx, content)
 	}
+
 	sniffingRequest := content.SniffingRequest
 	inbound, outbound := d.getLink(ctx)
 	if !sniffingRequest.Enabled {
@@ -366,7 +367,6 @@ func sniffer(ctx context.Context, cReader *cachedReader, metadataOnly bool, netw
 	}
 	return contentResult, contentErr
 }
-
 func (d *DefaultDispatcher) routedDispatch(ctx context.Context, link *transport.Link, destination net.Destination) {
 	ob := session.OutboundFromContext(ctx)
 	if hosts, ok := d.dns.(dns.HostsLookup); ok && destination.Address.Family().IsDomain() {

+ 1 - 1
app/dns/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/dns/config.proto
 

+ 1 - 1
app/dns/fakedns/fakedns.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/dns/fakedns/fakedns.proto
 

+ 1 - 1
app/log/command/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/log/command/config.proto
 

+ 1 - 1
app/log/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/log/config.proto
 

+ 1 - 1
app/metrics/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/metrics/config.proto
 

+ 14 - 0
app/observatory/burst/burst.go

@@ -0,0 +1,14 @@
+package burst
+
+import (
+	"math"
+	"time"
+)
+
+//go:generate go run github.com/v2fly/v2ray-core/v4/common/errors/errorgen
+
+const (
+	rttFailed = time.Duration(math.MaxInt64 - iota)
+	rttUntested
+	rttUnqualified
+)

+ 108 - 0
app/observatory/burst/burstobserver.go

@@ -0,0 +1,108 @@
+package burst
+
+import (
+	"context"
+	
+	"github.com/xtls/xray-core/core"
+	"github.com/xtls/xray-core/app/observatory"
+	"github.com/xtls/xray-core/common"
+	"github.com/xtls/xray-core/common/signal/done"
+	"github.com/xtls/xray-core/features/extension"
+	"github.com/xtls/xray-core/features/outbound"
+	"google.golang.org/protobuf/proto"
+	"sync"
+)
+
+type Observer struct {
+	config *Config
+	ctx    context.Context
+
+	statusLock sync.Mutex
+	hp         *HealthPing
+
+	finished *done.Instance
+
+	ohm outbound.Manager
+}
+
+func (o *Observer) GetObservation(ctx context.Context) (proto.Message, error) {
+	return &observatory.ObservationResult{Status: o.createResult()}, nil
+}
+
+func (o *Observer) createResult() []*observatory.OutboundStatus {
+	var result []*observatory.OutboundStatus
+	o.hp.access.Lock()
+	defer o.hp.access.Unlock()
+	for name, value := range o.hp.Results {
+		status := observatory.OutboundStatus{
+			Alive:           value.getStatistics().All != value.getStatistics().Fail,
+			Delay:           value.getStatistics().Average.Milliseconds(),
+			LastErrorReason: "",
+			OutboundTag:     name,
+			LastSeenTime:    0,
+			LastTryTime:     0,
+			HealthPing: &observatory.HealthPingMeasurementResult{
+				All:       int64(value.getStatistics().All),
+				Fail:      int64(value.getStatistics().Fail),
+				Deviation: int64(value.getStatistics().Deviation),
+				Average:   int64(value.getStatistics().Average),
+				Max:       int64(value.getStatistics().Max),
+				Min:       int64(value.getStatistics().Min),
+			},
+		}
+		result = append(result, &status)
+	}
+	return result
+}
+
+func (o *Observer) Type() interface{} {
+	return extension.ObservatoryType()
+}
+
+func (o *Observer) Start() error {
+	if o.config != nil && len(o.config.SubjectSelector) != 0 {
+		o.finished = done.New()
+		o.hp.StartScheduler(func() ([]string, error) {
+			hs, ok := o.ohm.(outbound.HandlerSelector)
+			if !ok {
+
+				return nil, newError("outbound.Manager is not a HandlerSelector")
+			}
+
+			outbounds := hs.Select(o.config.SubjectSelector)
+			return outbounds, nil
+		})
+	}
+	return nil
+}
+
+func (o *Observer) Close() error {
+	if o.finished != nil {
+		o.hp.StopScheduler()
+		return o.finished.Close()
+	}
+	return nil
+}
+
+func New(ctx context.Context, config *Config) (*Observer, error) {
+	var outboundManager outbound.Manager
+	err := core.RequireFeatures(ctx, func(om outbound.Manager) {
+		outboundManager = om
+	})
+	if err != nil {
+		return nil, newError("Cannot get depended features").Base(err)
+	}
+	hp := NewHealthPing(ctx, config.PingConfig)
+	return &Observer{
+		config: config,
+		ctx:    ctx,
+		ohm:    outboundManager,
+		hp:     hp,
+	}, nil
+}
+
+func init() {
+	common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) {
+		return New(ctx, config.(*Config))
+	}))
+}

+ 276 - 0
app/observatory/burst/config.pb.go

@@ -0,0 +1,276 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.32.0
+// 	protoc        v4.23.1
+// source: app/observatory/burst/config.proto
+
+package burst
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+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 Config struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// @Document The selectors for outbound under observation
+	SubjectSelector []string          `protobuf:"bytes,2,rep,name=subject_selector,json=subjectSelector,proto3" json:"subject_selector,omitempty"`
+	PingConfig      *HealthPingConfig `protobuf:"bytes,3,opt,name=ping_config,json=pingConfig,proto3" json:"ping_config,omitempty"`
+}
+
+func (x *Config) Reset() {
+	*x = Config{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_observatory_burst_config_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Config) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Config) ProtoMessage() {}
+
+func (x *Config) ProtoReflect() protoreflect.Message {
+	mi := &file_app_observatory_burst_config_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 Config.ProtoReflect.Descriptor instead.
+func (*Config) Descriptor() ([]byte, []int) {
+	return file_app_observatory_burst_config_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Config) GetSubjectSelector() []string {
+	if x != nil {
+		return x.SubjectSelector
+	}
+	return nil
+}
+
+func (x *Config) GetPingConfig() *HealthPingConfig {
+	if x != nil {
+		return x.PingConfig
+	}
+	return nil
+}
+
+type HealthPingConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// destination url, need 204 for success return
+	// default https://connectivitycheck.gstatic.com/generate_204
+	Destination string `protobuf:"bytes,1,opt,name=destination,proto3" json:"destination,omitempty"`
+	// connectivity check url
+	Connectivity string `protobuf:"bytes,2,opt,name=connectivity,proto3" json:"connectivity,omitempty"`
+	// health check interval, int64 values of time.Duration
+	Interval int64 `protobuf:"varint,3,opt,name=interval,proto3" json:"interval,omitempty"`
+	// sampling count is the amount of recent ping results which are kept for calculation
+	SamplingCount int32 `protobuf:"varint,4,opt,name=samplingCount,proto3" json:"samplingCount,omitempty"`
+	// ping timeout, int64 values of time.Duration
+	Timeout int64 `protobuf:"varint,5,opt,name=timeout,proto3" json:"timeout,omitempty"`
+}
+
+func (x *HealthPingConfig) Reset() {
+	*x = HealthPingConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_observatory_burst_config_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *HealthPingConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HealthPingConfig) ProtoMessage() {}
+
+func (x *HealthPingConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_app_observatory_burst_config_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 HealthPingConfig.ProtoReflect.Descriptor instead.
+func (*HealthPingConfig) Descriptor() ([]byte, []int) {
+	return file_app_observatory_burst_config_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *HealthPingConfig) GetDestination() string {
+	if x != nil {
+		return x.Destination
+	}
+	return ""
+}
+
+func (x *HealthPingConfig) GetConnectivity() string {
+	if x != nil {
+		return x.Connectivity
+	}
+	return ""
+}
+
+func (x *HealthPingConfig) GetInterval() int64 {
+	if x != nil {
+		return x.Interval
+	}
+	return 0
+}
+
+func (x *HealthPingConfig) GetSamplingCount() int32 {
+	if x != nil {
+		return x.SamplingCount
+	}
+	return 0
+}
+
+func (x *HealthPingConfig) GetTimeout() int64 {
+	if x != nil {
+		return x.Timeout
+	}
+	return 0
+}
+
+var File_app_observatory_burst_config_proto protoreflect.FileDescriptor
+
+var file_app_observatory_burst_config_proto_rawDesc = []byte{
+	0x0a, 0x22, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72,
+	0x79, 0x2f, 0x62, 0x75, 0x72, 0x73, 0x74, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1f, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e,
+	0x61, 0x70, 0x70, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x2e,
+	0x62, 0x75, 0x72, 0x73, 0x74, 0x22, 0x87, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x12, 0x29, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x6c, 0x65,
+	0x63, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a,
+	0x65, 0x63, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x52, 0x0a, 0x0b, 0x70,
+	0x69, 0x6e, 0x67, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x31, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x61, 0x70, 0x70,
+	0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x62, 0x75, 0x72,
+	0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x50, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
+	0xb4, 0x01, 0x0a, 0x10, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x50, 0x69, 0x6e, 0x67, 0x43, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69,
+	0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
+	0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f,
+	0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x69,
+	0x6e, 0x67, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x73,
+	0x61, 0x6d, 0x70, 0x6c, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07,
+	0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74,
+	0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x42, 0x70, 0x0a, 0x1e, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72,
+	0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f,
+	0x72, 0x79, 0x2e, 0x62, 0x75, 0x72, 0x73, 0x74, 0x50, 0x01, 0x5a, 0x2f, 0x67, 0x69, 0x74, 0x68,
+	0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79,
+	0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76,
+	0x61, 0x74, 0x6f, 0x72, 0x79, 0x2f, 0x62, 0x75, 0x72, 0x73, 0x74, 0xaa, 0x02, 0x1a, 0x58, 0x72,
+	0x61, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f,
+	0x72, 0x79, 0x2e, 0x42, 0x75, 0x72, 0x73, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_app_observatory_burst_config_proto_rawDescOnce sync.Once
+	file_app_observatory_burst_config_proto_rawDescData = file_app_observatory_burst_config_proto_rawDesc
+)
+
+func file_app_observatory_burst_config_proto_rawDescGZIP() []byte {
+	file_app_observatory_burst_config_proto_rawDescOnce.Do(func() {
+		file_app_observatory_burst_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_app_observatory_burst_config_proto_rawDescData)
+	})
+	return file_app_observatory_burst_config_proto_rawDescData
+}
+
+var file_app_observatory_burst_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_app_observatory_burst_config_proto_goTypes = []interface{}{
+	(*Config)(nil),           // 0: xray.core.app.observatory.burst.Config
+	(*HealthPingConfig)(nil), // 1: xray.core.app.observatory.burst.HealthPingConfig
+}
+var file_app_observatory_burst_config_proto_depIdxs = []int32{
+	1, // 0: xray.core.app.observatory.burst.Config.ping_config:type_name -> xray.core.app.observatory.burst.HealthPingConfig
+	1, // [1:1] is the sub-list for method output_type
+	1, // [1:1] is the sub-list for method input_type
+	1, // [1:1] is the sub-list for extension type_name
+	1, // [1:1] is the sub-list for extension extendee
+	0, // [0:1] is the sub-list for field type_name
+}
+
+func init() { file_app_observatory_burst_config_proto_init() }
+func file_app_observatory_burst_config_proto_init() {
+	if File_app_observatory_burst_config_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_app_observatory_burst_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Config); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_observatory_burst_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*HealthPingConfig); 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_app_observatory_burst_config_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_app_observatory_burst_config_proto_goTypes,
+		DependencyIndexes: file_app_observatory_burst_config_proto_depIdxs,
+		MessageInfos:      file_app_observatory_burst_config_proto_msgTypes,
+	}.Build()
+	File_app_observatory_burst_config_proto = out.File
+	file_app_observatory_burst_config_proto_rawDesc = nil
+	file_app_observatory_burst_config_proto_goTypes = nil
+	file_app_observatory_burst_config_proto_depIdxs = nil
+}

+ 29 - 0
app/observatory/burst/config.proto

@@ -0,0 +1,29 @@
+syntax = "proto3";
+
+package xray.core.app.observatory.burst;
+option csharp_namespace = "Xray.App.Observatory.Burst";
+option go_package = "github.com/xtls/xray-core/app/observatory/burst";
+option java_package = "com.xray.app.observatory.burst";
+option java_multiple_files = true;
+
+message Config {
+  /* @Document The selectors for outbound under observation
+  */
+  repeated string subject_selector = 2;
+
+  HealthPingConfig ping_config = 3;
+}
+
+message HealthPingConfig {
+  // destination url, need 204 for success return
+  // default https://connectivitycheck.gstatic.com/generate_204
+  string destination = 1;
+  // connectivity check url
+  string connectivity = 2;
+  // health check interval, int64 values of time.Duration
+  int64 interval = 3;
+  // sampling count is the amount of recent ping results which are kept for calculation
+  int32 samplingCount = 4;
+  // ping timeout, int64 values of time.Duration
+  int64 timeout = 5;
+}

+ 9 - 0
app/observatory/burst/errors.generated.go

@@ -0,0 +1,9 @@
+package burst
+
+import "github.com/xtls/xray-core/common/errors"
+
+type errPathObjHolder struct{}
+
+func newError(values ...interface{}) *errors.Error {
+	return errors.New(values...).WithPathObj(errPathObjHolder{})
+}

+ 244 - 0
app/observatory/burst/healthping.go

@@ -0,0 +1,244 @@
+package burst
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/xtls/xray-core/common/dice"
+)
+
+// HealthPingSettings holds settings for health Checker
+type HealthPingSettings struct {
+	Destination   string        `json:"destination"`
+	Connectivity  string        `json:"connectivity"`
+	Interval      time.Duration `json:"interval"`
+	SamplingCount int           `json:"sampling"`
+	Timeout       time.Duration `json:"timeout"`
+}
+
+// HealthPing is the health checker for balancers
+type HealthPing struct {
+	ctx         context.Context
+	access      sync.Mutex
+	ticker      *time.Ticker
+	tickerClose chan struct{}
+
+	Settings *HealthPingSettings
+	Results  map[string]*HealthPingRTTS
+}
+
+// NewHealthPing creates a new HealthPing with settings
+func NewHealthPing(ctx context.Context, config *HealthPingConfig) *HealthPing {
+	settings := &HealthPingSettings{}
+	if config != nil {
+		settings = &HealthPingSettings{
+			Connectivity:  strings.TrimSpace(config.Connectivity),
+			Destination:   strings.TrimSpace(config.Destination),
+			Interval:      time.Duration(config.Interval),
+			SamplingCount: int(config.SamplingCount),
+			Timeout:       time.Duration(config.Timeout),
+		}
+	}
+	if settings.Destination == "" {
+		// Destination URL, need 204 for success return default to chromium
+		// https://github.com/chromium/chromium/blob/main/components/safety_check/url_constants.cc#L10
+		// https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/safety_check/url_constants.cc#10
+		settings.Destination = "https://connectivitycheck.gstatic.com/generate_204"
+	}
+	if settings.Interval == 0 {
+		settings.Interval = time.Duration(1) * time.Minute
+	} else if settings.Interval < 10 {
+		newError("health check interval is too small, 10s is applied").AtWarning().WriteToLog()
+		settings.Interval = time.Duration(10) * time.Second
+	}
+	if settings.SamplingCount <= 0 {
+		settings.SamplingCount = 10
+	}
+	if settings.Timeout <= 0 {
+		// results are saved after all health pings finish,
+		// a larger timeout could possibly makes checks run longer
+		settings.Timeout = time.Duration(5) * time.Second
+	}
+	return &HealthPing{
+		ctx:      ctx,
+		Settings: settings,
+		Results:  nil,
+	}
+}
+
+// StartScheduler implements the HealthChecker
+func (h *HealthPing) StartScheduler(selector func() ([]string, error)) {
+	if h.ticker != nil {
+		return
+	}
+	interval := h.Settings.Interval * time.Duration(h.Settings.SamplingCount)
+	ticker := time.NewTicker(interval)
+	tickerClose := make(chan struct{})
+	h.ticker = ticker
+	h.tickerClose = tickerClose
+	go func() {
+		for {
+			go func() {
+				tags, err := selector()
+				if err != nil {
+					newError("error select outbounds for scheduled health check: ", err).AtWarning().WriteToLog()
+					return
+				}
+				h.doCheck(tags, interval, h.Settings.SamplingCount)
+				h.Cleanup(tags)
+			}()
+			select {
+			case <-ticker.C:
+				continue
+			case <-tickerClose:
+				return
+			}
+		}
+	}()
+}
+
+// StopScheduler implements the HealthChecker
+func (h *HealthPing) StopScheduler() {
+	if h.ticker == nil {
+		return
+	}
+	h.ticker.Stop()
+	h.ticker = nil
+	close(h.tickerClose)
+	h.tickerClose = nil
+}
+
+// Check implements the HealthChecker
+func (h *HealthPing) Check(tags []string) error {
+	if len(tags) == 0 {
+		return nil
+	}
+	newError("perform one-time health check for tags ", tags).AtInfo().WriteToLog()
+	h.doCheck(tags, 0, 1)
+	return nil
+}
+
+type rtt struct {
+	handler string
+	value   time.Duration
+}
+
+// doCheck performs the 'rounds' amount checks in given 'duration'. You should make
+// sure all tags are valid for current balancer
+func (h *HealthPing) doCheck(tags []string, duration time.Duration, rounds int) {
+	count := len(tags) * rounds
+	if count == 0 {
+		return
+	}
+	ch := make(chan *rtt, count)
+
+	for _, tag := range tags {
+		handler := tag
+		client := newPingClient(
+			h.ctx,
+			h.Settings.Destination,
+			h.Settings.Timeout,
+			handler,
+		)
+		for i := 0; i < rounds; i++ {
+			delay := time.Duration(0)
+			if duration > 0 {
+				delay = time.Duration(dice.Roll(int(duration)))
+			}
+			time.AfterFunc(delay, func() {
+				newError("checking ", handler).AtDebug().WriteToLog()
+				delay, err := client.MeasureDelay()
+				if err == nil {
+					ch <- &rtt{
+						handler: handler,
+						value:   delay,
+					}
+					return
+				}
+				if !h.checkConnectivity() {
+					newError("network is down").AtWarning().WriteToLog()
+					ch <- &rtt{
+						handler: handler,
+						value:   0,
+					}
+					return
+				}
+				newError(fmt.Sprintf(
+					"error ping %s with %s: %s",
+					h.Settings.Destination,
+					handler,
+					err,
+				)).AtWarning().WriteToLog()
+				ch <- &rtt{
+					handler: handler,
+					value:   rttFailed,
+				}
+			})
+		}
+	}
+	for i := 0; i < count; i++ {
+		rtt := <-ch
+		if rtt.value > 0 {
+			// should not put results when network is down
+			h.PutResult(rtt.handler, rtt.value)
+		}
+	}
+}
+
+// PutResult put a ping rtt to results
+func (h *HealthPing) PutResult(tag string, rtt time.Duration) {
+	h.access.Lock()
+	defer h.access.Unlock()
+	if h.Results == nil {
+		h.Results = make(map[string]*HealthPingRTTS)
+	}
+	r, ok := h.Results[tag]
+	if !ok {
+		// validity is 2 times to sampling period, since the check are
+		// distributed in the time line randomly, in extreme cases,
+		// previous checks are distributed on the left, and latters
+		// on the right
+		validity := h.Settings.Interval * time.Duration(h.Settings.SamplingCount) * 2
+		r = NewHealthPingResult(h.Settings.SamplingCount, validity)
+		h.Results[tag] = r
+	}
+	r.Put(rtt)
+}
+
+// Cleanup removes results of removed handlers,
+// tags should be all valid tags of the Balancer now
+func (h *HealthPing) Cleanup(tags []string) {
+	h.access.Lock()
+	defer h.access.Unlock()
+	for tag := range h.Results {
+		found := false
+		for _, v := range tags {
+			if tag == v {
+				found = true
+				break
+			}
+		}
+		if !found {
+			delete(h.Results, tag)
+		}
+	}
+}
+
+// checkConnectivity checks the network connectivity, it returns
+// true if network is good or "connectivity check url" not set
+func (h *HealthPing) checkConnectivity() bool {
+	if h.Settings.Connectivity == "" {
+		return true
+	}
+	tester := newDirectPingClient(
+		h.Settings.Connectivity,
+		h.Settings.Timeout,
+	)
+	if _, err := tester.MeasureDelay(); err != nil {
+		return false
+	}
+	return true
+}

+ 143 - 0
app/observatory/burst/healthping_result.go

@@ -0,0 +1,143 @@
+package burst
+
+import (
+	"math"
+	"time"
+)
+
+// HealthPingStats is the statistics of HealthPingRTTS
+type HealthPingStats struct {
+	All       int
+	Fail      int
+	Deviation time.Duration
+	Average   time.Duration
+	Max       time.Duration
+	Min       time.Duration
+}
+
+// HealthPingRTTS holds ping rtts for health Checker
+type HealthPingRTTS struct {
+	idx      int
+	cap      int
+	validity time.Duration
+	rtts     []*pingRTT
+
+	lastUpdateAt time.Time
+	stats        *HealthPingStats
+}
+
+type pingRTT struct {
+	time  time.Time
+	value time.Duration
+}
+
+// NewHealthPingResult returns a *HealthPingResult with specified capacity
+func NewHealthPingResult(cap int, validity time.Duration) *HealthPingRTTS {
+	return &HealthPingRTTS{cap: cap, validity: validity}
+}
+
+// Get gets statistics of the HealthPingRTTS
+func (h *HealthPingRTTS) Get() *HealthPingStats {
+	return h.getStatistics()
+}
+
+// GetWithCache get statistics and write cache for next call
+// Make sure use Mutex.Lock() before calling it, RWMutex.RLock()
+// is not an option since it writes cache
+func (h *HealthPingRTTS) GetWithCache() *HealthPingStats {
+	lastPutAt := h.rtts[h.idx].time
+	now := time.Now()
+	if h.stats == nil || h.lastUpdateAt.Before(lastPutAt) || h.findOutdated(now) >= 0 {
+		h.stats = h.getStatistics()
+		h.lastUpdateAt = now
+	}
+	return h.stats
+}
+
+// Put puts a new rtt to the HealthPingResult
+func (h *HealthPingRTTS) Put(d time.Duration) {
+	if h.rtts == nil {
+		h.rtts = make([]*pingRTT, h.cap)
+		for i := 0; i < h.cap; i++ {
+			h.rtts[i] = &pingRTT{}
+		}
+		h.idx = -1
+	}
+	h.idx = h.calcIndex(1)
+	now := time.Now()
+	h.rtts[h.idx].time = now
+	h.rtts[h.idx].value = d
+}
+
+func (h *HealthPingRTTS) calcIndex(step int) int {
+	idx := h.idx
+	idx += step
+	if idx >= h.cap {
+		idx %= h.cap
+	}
+	return idx
+}
+
+func (h *HealthPingRTTS) getStatistics() *HealthPingStats {
+	stats := &HealthPingStats{}
+	stats.Fail = 0
+	stats.Max = 0
+	stats.Min = rttFailed
+	sum := time.Duration(0)
+	cnt := 0
+	validRTTs := make([]time.Duration, 0)
+	for _, rtt := range h.rtts {
+		switch {
+		case rtt.value == 0 || time.Since(rtt.time) > h.validity:
+			continue
+		case rtt.value == rttFailed:
+			stats.Fail++
+			continue
+		}
+		cnt++
+		sum += rtt.value
+		validRTTs = append(validRTTs, rtt.value)
+		if stats.Max < rtt.value {
+			stats.Max = rtt.value
+		}
+		if stats.Min > rtt.value {
+			stats.Min = rtt.value
+		}
+	}
+	stats.All = cnt + stats.Fail
+	if cnt == 0 {
+		stats.Min = 0
+		return stats
+	}
+	stats.Average = time.Duration(int(sum) / cnt)
+	var std float64
+	if cnt < 2 {
+		// no enough data for standard deviation, we assume it's half of the average rtt
+		// if we don't do this, standard deviation of 1 round tested nodes is 0, will always
+		// selected before 2 or more rounds tested nodes
+		std = float64(stats.Average / 2)
+	} else {
+		variance := float64(0)
+		for _, rtt := range validRTTs {
+			variance += math.Pow(float64(rtt-stats.Average), 2)
+		}
+		std = math.Sqrt(variance / float64(cnt))
+	}
+	stats.Deviation = time.Duration(std)
+	return stats
+}
+
+func (h *HealthPingRTTS) findOutdated(now time.Time) int {
+	for i := h.cap - 1; i < 2*h.cap; i++ {
+		// from oldest to latest
+		idx := h.calcIndex(i)
+		validity := h.rtts[idx].time.Add(h.validity)
+		if h.lastUpdateAt.After(validity) {
+			return idx
+		}
+		if validity.Before(now) {
+			return idx
+		}
+	}
+	return -1
+}

+ 106 - 0
app/observatory/burst/healthping_result_test.go

@@ -0,0 +1,106 @@
+package burst_test
+
+import (
+	"math"
+	reflect "reflect"
+	"testing"
+	"time"
+
+	"github.com/xtls/xray-core/app/observatory/burst"
+)
+
+func TestHealthPingResults(t *testing.T) {
+	rtts := []int64{60, 140, 60, 140, 60, 60, 140, 60, 140}
+	hr := burst.NewHealthPingResult(4, time.Hour)
+	for _, rtt := range rtts {
+		hr.Put(time.Duration(rtt))
+	}
+	rttFailed := time.Duration(math.MaxInt64)
+	expected := &burst.HealthPingStats{
+		All:       4,
+		Fail:      0,
+		Deviation: 40,
+		Average:   100,
+		Max:       140,
+		Min:       60,
+	}
+	actual := hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("expected: %v, actual: %v", expected, actual)
+	}
+	hr.Put(rttFailed)
+	hr.Put(rttFailed)
+	expected.Fail = 2
+	actual = hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("failed half-failures test, expected: %v, actual: %v", expected, actual)
+	}
+	hr.Put(rttFailed)
+	hr.Put(rttFailed)
+	expected = &burst.HealthPingStats{
+		All:       4,
+		Fail:      4,
+		Deviation: 0,
+		Average:   0,
+		Max:       0,
+		Min:       0,
+	}
+	actual = hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("failed all-failures test, expected: %v, actual: %v", expected, actual)
+	}
+}
+
+func TestHealthPingResultsIgnoreOutdated(t *testing.T) {
+	rtts := []int64{60, 140, 60, 140}
+	hr := burst.NewHealthPingResult(4, time.Duration(10)*time.Millisecond)
+	for i, rtt := range rtts {
+		if i == 2 {
+			// wait for previous 2 outdated
+			time.Sleep(time.Duration(10) * time.Millisecond)
+		}
+		hr.Put(time.Duration(rtt))
+	}
+	hr.Get()
+	expected := &burst.HealthPingStats{
+		All:       2,
+		Fail:      0,
+		Deviation: 40,
+		Average:   100,
+		Max:       140,
+		Min:       60,
+	}
+	actual := hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("failed 'half-outdated' test, expected: %v, actual: %v", expected, actual)
+	}
+	// wait for all outdated
+	time.Sleep(time.Duration(10) * time.Millisecond)
+	expected = &burst.HealthPingStats{
+		All:       0,
+		Fail:      0,
+		Deviation: 0,
+		Average:   0,
+		Max:       0,
+		Min:       0,
+	}
+	actual = hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("failed 'outdated / not-tested' test, expected: %v, actual: %v", expected, actual)
+	}
+
+	hr.Put(time.Duration(60))
+	expected = &burst.HealthPingStats{
+		All:  1,
+		Fail: 0,
+		// 1 sample, std=0.5rtt
+		Deviation: 30,
+		Average:   60,
+		Max:       60,
+		Min:       60,
+	}
+	actual = hr.Get()
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("expected: %v, actual: %v", expected, actual)
+	}
+}

+ 69 - 0
app/observatory/burst/ping.go

@@ -0,0 +1,69 @@
+package burst
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/xtls/xray-core/common/net"
+	"github.com/xtls/xray-core/transport/internet/tagged"
+)
+
+type pingClient struct {
+	destination string
+	httpClient  *http.Client
+}
+
+func newPingClient(ctx context.Context, destination string, timeout time.Duration, handler string) *pingClient {
+	return &pingClient{
+		destination: destination,
+		httpClient:  newHTTPClient(ctx, handler, timeout),
+	}
+}
+
+func newDirectPingClient(destination string, timeout time.Duration) *pingClient {
+	return &pingClient{
+		destination: destination,
+		httpClient:  &http.Client{Timeout: timeout},
+	}
+}
+
+func newHTTPClient(ctxv context.Context, handler string, timeout time.Duration) *http.Client {
+	tr := &http.Transport{
+		DisableKeepAlives: true,
+		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+			dest, err := net.ParseDestination(network + ":" + addr)
+			if err != nil {
+				return nil, err
+			}
+			return tagged.Dialer(ctxv, dest, handler)
+		},
+	}
+	return &http.Client{
+		Transport: tr,
+		Timeout:   timeout,
+		// don't follow redirect
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+}
+
+// MeasureDelay returns the delay time of the request to dest
+func (s *pingClient) MeasureDelay() (time.Duration, error) {
+	if s.httpClient == nil {
+		panic("pingClient no initialized")
+	}
+	req, err := http.NewRequest(http.MethodHead, s.destination, nil)
+	if err != nil {
+		return rttFailed, err
+	}
+	start := time.Now()
+	resp, err := s.httpClient.Do(req)
+	if err != nil {
+		return rttFailed, err
+	}
+	// don't wait for body
+	resp.Body.Close()
+	return time.Since(start), nil
+}

+ 1 - 1
app/observatory/command/command.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/observatory/command/command.proto
 

+ 195 - 71
app/observatory/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/observatory/config.proto
 
@@ -67,6 +67,93 @@ func (x *ObservationResult) GetStatus() []*OutboundStatus {
 	return nil
 }
 
+type HealthPingMeasurementResult struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	All       int64 `protobuf:"varint,1,opt,name=all,proto3" json:"all,omitempty"`
+	Fail      int64 `protobuf:"varint,2,opt,name=fail,proto3" json:"fail,omitempty"`
+	Deviation int64 `protobuf:"varint,3,opt,name=deviation,proto3" json:"deviation,omitempty"`
+	Average   int64 `protobuf:"varint,4,opt,name=average,proto3" json:"average,omitempty"`
+	Max       int64 `protobuf:"varint,5,opt,name=max,proto3" json:"max,omitempty"`
+	Min       int64 `protobuf:"varint,6,opt,name=min,proto3" json:"min,omitempty"`
+}
+
+func (x *HealthPingMeasurementResult) Reset() {
+	*x = HealthPingMeasurementResult{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_observatory_config_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *HealthPingMeasurementResult) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HealthPingMeasurementResult) ProtoMessage() {}
+
+func (x *HealthPingMeasurementResult) ProtoReflect() protoreflect.Message {
+	mi := &file_app_observatory_config_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 HealthPingMeasurementResult.ProtoReflect.Descriptor instead.
+func (*HealthPingMeasurementResult) Descriptor() ([]byte, []int) {
+	return file_app_observatory_config_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *HealthPingMeasurementResult) GetAll() int64 {
+	if x != nil {
+		return x.All
+	}
+	return 0
+}
+
+func (x *HealthPingMeasurementResult) GetFail() int64 {
+	if x != nil {
+		return x.Fail
+	}
+	return 0
+}
+
+func (x *HealthPingMeasurementResult) GetDeviation() int64 {
+	if x != nil {
+		return x.Deviation
+	}
+	return 0
+}
+
+func (x *HealthPingMeasurementResult) GetAverage() int64 {
+	if x != nil {
+		return x.Average
+	}
+	return 0
+}
+
+func (x *HealthPingMeasurementResult) GetMax() int64 {
+	if x != nil {
+		return x.Max
+	}
+	return 0
+}
+
+func (x *HealthPingMeasurementResult) GetMin() int64 {
+	if x != nil {
+		return x.Min
+	}
+	return 0
+}
+
 type OutboundStatus struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -90,13 +177,14 @@ type OutboundStatus struct {
 	LastSeenTime int64 `protobuf:"varint,5,opt,name=last_seen_time,json=lastSeenTime,proto3" json:"last_seen_time,omitempty"`
 	// @Document The time this outbound is tried
 	// @Type id.outboundTag
-	LastTryTime int64 `protobuf:"varint,6,opt,name=last_try_time,json=lastTryTime,proto3" json:"last_try_time,omitempty"`
+	LastTryTime int64                        `protobuf:"varint,6,opt,name=last_try_time,json=lastTryTime,proto3" json:"last_try_time,omitempty"`
+	HealthPing  *HealthPingMeasurementResult `protobuf:"bytes,7,opt,name=health_ping,json=healthPing,proto3" json:"health_ping,omitempty"`
 }
 
 func (x *OutboundStatus) Reset() {
 	*x = OutboundStatus{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_observatory_config_proto_msgTypes[1]
+		mi := &file_app_observatory_config_proto_msgTypes[2]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -109,7 +197,7 @@ func (x *OutboundStatus) String() string {
 func (*OutboundStatus) ProtoMessage() {}
 
 func (x *OutboundStatus) ProtoReflect() protoreflect.Message {
-	mi := &file_app_observatory_config_proto_msgTypes[1]
+	mi := &file_app_observatory_config_proto_msgTypes[2]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -122,7 +210,7 @@ func (x *OutboundStatus) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use OutboundStatus.ProtoReflect.Descriptor instead.
 func (*OutboundStatus) Descriptor() ([]byte, []int) {
-	return file_app_observatory_config_proto_rawDescGZIP(), []int{1}
+	return file_app_observatory_config_proto_rawDescGZIP(), []int{2}
 }
 
 func (x *OutboundStatus) GetAlive() bool {
@@ -167,6 +255,13 @@ func (x *OutboundStatus) GetLastTryTime() int64 {
 	return 0
 }
 
+func (x *OutboundStatus) GetHealthPing() *HealthPingMeasurementResult {
+	if x != nil {
+		return x.HealthPing
+	}
+	return nil
+}
+
 type ProbeResult struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -187,7 +282,7 @@ type ProbeResult struct {
 func (x *ProbeResult) Reset() {
 	*x = ProbeResult{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_observatory_config_proto_msgTypes[2]
+		mi := &file_app_observatory_config_proto_msgTypes[3]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -200,7 +295,7 @@ func (x *ProbeResult) String() string {
 func (*ProbeResult) ProtoMessage() {}
 
 func (x *ProbeResult) ProtoReflect() protoreflect.Message {
-	mi := &file_app_observatory_config_proto_msgTypes[2]
+	mi := &file_app_observatory_config_proto_msgTypes[3]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -213,7 +308,7 @@ func (x *ProbeResult) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ProbeResult.ProtoReflect.Descriptor instead.
 func (*ProbeResult) Descriptor() ([]byte, []int) {
-	return file_app_observatory_config_proto_rawDescGZIP(), []int{2}
+	return file_app_observatory_config_proto_rawDescGZIP(), []int{3}
 }
 
 func (x *ProbeResult) GetAlive() bool {
@@ -250,7 +345,7 @@ type Intensity struct {
 func (x *Intensity) Reset() {
 	*x = Intensity{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_observatory_config_proto_msgTypes[3]
+		mi := &file_app_observatory_config_proto_msgTypes[4]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -263,7 +358,7 @@ func (x *Intensity) String() string {
 func (*Intensity) ProtoMessage() {}
 
 func (x *Intensity) ProtoReflect() protoreflect.Message {
-	mi := &file_app_observatory_config_proto_msgTypes[3]
+	mi := &file_app_observatory_config_proto_msgTypes[4]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -276,7 +371,7 @@ func (x *Intensity) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Intensity.ProtoReflect.Descriptor instead.
 func (*Intensity) Descriptor() ([]byte, []int) {
-	return file_app_observatory_config_proto_rawDescGZIP(), []int{3}
+	return file_app_observatory_config_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *Intensity) GetProbeInterval() uint32 {
@@ -301,7 +396,7 @@ type Config struct {
 func (x *Config) Reset() {
 	*x = Config{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_observatory_config_proto_msgTypes[4]
+		mi := &file_app_observatory_config_proto_msgTypes[5]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -314,7 +409,7 @@ func (x *Config) String() string {
 func (*Config) ProtoMessage() {}
 
 func (x *Config) ProtoReflect() protoreflect.Message {
-	mi := &file_app_observatory_config_proto_msgTypes[4]
+	mi := &file_app_observatory_config_proto_msgTypes[5]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -327,7 +422,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Config.ProtoReflect.Descriptor instead.
 func (*Config) Descriptor() ([]byte, []int) {
-	return file_app_observatory_config_proto_rawDescGZIP(), []int{4}
+	return file_app_observatory_config_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *Config) GetSubjectSelector() []string {
@@ -370,47 +465,62 @@ var file_app_observatory_config_proto_rawDesc = []byte{
 	0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x6f,
 	0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x4f, 0x75, 0x74, 0x62, 0x6f,
 	0x75, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
-	0x73, 0x22, 0xd5, 0x01, 0x0a, 0x0e, 0x4f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x74,
-	0x61, 0x74, 0x75, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x08, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65,
-	0x6c, 0x61, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x61, 0x79,
-	0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x72,
-	0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73,
-	0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c,
-	0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x54, 0x61, 0x67, 0x12,
-	0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65, 0x6e, 0x5f, 0x74, 0x69, 0x6d,
-	0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65, 0x65,
-	0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x74, 0x72,
-	0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6c, 0x61,
-	0x73, 0x74, 0x54, 0x72, 0x79, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x65, 0x0a, 0x0b, 0x50, 0x72, 0x6f,
-	0x62, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x76,
-	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x12, 0x14,
-	0x0a, 0x05, 0x64, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64,
-	0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x65, 0x72, 0x72,
-	0x6f, 0x72, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0f, 0x6c, 0x61, 0x73, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e,
-	0x22, 0x32, 0x0a, 0x09, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x79, 0x12, 0x25, 0x0a,
-	0x0e, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18,
-	0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65,
-	0x72, 0x76, 0x61, 0x6c, 0x22, 0xa6, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
-	0x29, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x6c, 0x65, 0x63,
-	0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a, 0x65,
-	0x63, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x72,
-	0x6f, 0x62, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
-	0x72, 0x6f, 0x62, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x62, 0x65,
-	0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
-	0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x2d,
-	0x0a, 0x12, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72,
-	0x65, 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x65, 0x6e, 0x61, 0x62,
-	0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x5e, 0x0a,
-	0x18, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x6f, 0x62,
-	0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x50, 0x01, 0x5a, 0x29, 0x67, 0x69, 0x74,
-	0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61,
-	0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72,
-	0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0xaa, 0x02, 0x14, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x41, 0x70,
-	0x70, 0x2e, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x62, 0x06, 0x70,
-	0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x73, 0x22, 0x9f, 0x01, 0x0a, 0x1b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x50, 0x69, 0x6e, 0x67,
+	0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03,
+	0x61, 0x6c, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x03, 0x52, 0x04, 0x66, 0x61, 0x69, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x64, 0x65, 0x76, 0x69,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x76, 0x65, 0x72, 0x61, 0x67, 0x65,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x61, 0x76, 0x65, 0x72, 0x61, 0x67, 0x65, 0x12,
+	0x10, 0x0a, 0x03, 0x6d, 0x61, 0x78, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x6d, 0x61,
+	0x78, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03,
+	0x6d, 0x69, 0x6e, 0x22, 0xae, 0x02, 0x0a, 0x0e, 0x4f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64,
+	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x12, 0x14, 0x0a, 0x05,
+	0x64, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c,
+	0x61, 0x79, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72,
+	0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c,
+	0x61, 0x73, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x21,
+	0x0a, 0x0c, 0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x54, 0x61,
+	0x67, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65, 0x6e, 0x5f, 0x74,
+	0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x53,
+	0x65, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x6c, 0x61, 0x73, 0x74, 0x5f,
+	0x74, 0x72, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
+	0x6c, 0x61, 0x73, 0x74, 0x54, 0x72, 0x79, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x57, 0x0a, 0x0b, 0x68,
+	0x65, 0x61, 0x6c, 0x74, 0x68, 0x5f, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x36, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x61, 0x70, 0x70,
+	0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x48, 0x65, 0x61,
+	0x6c, 0x74, 0x68, 0x50, 0x69, 0x6e, 0x67, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65,
+	0x6e, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68,
+	0x50, 0x69, 0x6e, 0x67, 0x22, 0x65, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x62, 0x65, 0x52, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c,
+	0x61, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x61, 0x79, 0x12,
+	0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x72, 0x65,
+	0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74,
+	0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x32, 0x0a, 0x09, 0x49,
+	0x6e, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x79, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x62,
+	0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
+	0x52, 0x0d, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x22,
+	0xa6, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x75,
+	0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x02,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x6c,
+	0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x5f, 0x75,
+	0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x55,
+	0x72, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x62, 0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65,
+	0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x62,
+	0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x12, 0x65, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6e,
+	0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x5e, 0x0a, 0x18, 0x63, 0x6f, 0x6d, 0x2e,
+	0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61,
+	0x74, 0x6f, 0x72, 0x79, 0x50, 0x01, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72,
+	0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72,
+	0x79, 0xaa, 0x02, 0x14, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x62, 0x73,
+	0x65, 0x72, 0x76, 0x61, 0x74, 0x6f, 0x72, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -425,21 +535,23 @@ func file_app_observatory_config_proto_rawDescGZIP() []byte {
 	return file_app_observatory_config_proto_rawDescData
 }
 
-var file_app_observatory_config_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
+var file_app_observatory_config_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
 var file_app_observatory_config_proto_goTypes = []interface{}{
-	(*ObservationResult)(nil), // 0: xray.core.app.observatory.ObservationResult
-	(*OutboundStatus)(nil),    // 1: xray.core.app.observatory.OutboundStatus
-	(*ProbeResult)(nil),       // 2: xray.core.app.observatory.ProbeResult
-	(*Intensity)(nil),         // 3: xray.core.app.observatory.Intensity
-	(*Config)(nil),            // 4: xray.core.app.observatory.Config
+	(*ObservationResult)(nil),           // 0: xray.core.app.observatory.ObservationResult
+	(*HealthPingMeasurementResult)(nil), // 1: xray.core.app.observatory.HealthPingMeasurementResult
+	(*OutboundStatus)(nil),              // 2: xray.core.app.observatory.OutboundStatus
+	(*ProbeResult)(nil),                 // 3: xray.core.app.observatory.ProbeResult
+	(*Intensity)(nil),                   // 4: xray.core.app.observatory.Intensity
+	(*Config)(nil),                      // 5: xray.core.app.observatory.Config
 }
 var file_app_observatory_config_proto_depIdxs = []int32{
-	1, // 0: xray.core.app.observatory.ObservationResult.status:type_name -> xray.core.app.observatory.OutboundStatus
-	1, // [1:1] is the sub-list for method output_type
-	1, // [1:1] is the sub-list for method input_type
-	1, // [1:1] is the sub-list for extension type_name
-	1, // [1:1] is the sub-list for extension extendee
-	0, // [0:1] is the sub-list for field type_name
+	2, // 0: xray.core.app.observatory.ObservationResult.status:type_name -> xray.core.app.observatory.OutboundStatus
+	1, // 1: xray.core.app.observatory.OutboundStatus.health_ping:type_name -> xray.core.app.observatory.HealthPingMeasurementResult
+	2, // [2:2] is the sub-list for method output_type
+	2, // [2:2] 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_app_observatory_config_proto_init() }
@@ -461,7 +573,7 @@ func file_app_observatory_config_proto_init() {
 			}
 		}
 		file_app_observatory_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*OutboundStatus); i {
+			switch v := v.(*HealthPingMeasurementResult); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -473,7 +585,7 @@ func file_app_observatory_config_proto_init() {
 			}
 		}
 		file_app_observatory_config_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*ProbeResult); i {
+			switch v := v.(*OutboundStatus); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -485,7 +597,7 @@ func file_app_observatory_config_proto_init() {
 			}
 		}
 		file_app_observatory_config_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Intensity); i {
+			switch v := v.(*ProbeResult); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -497,6 +609,18 @@ func file_app_observatory_config_proto_init() {
 			}
 		}
 		file_app_observatory_config_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Intensity); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_observatory_config_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*Config); i {
 			case 0:
 				return &v.state
@@ -515,7 +639,7 @@ func file_app_observatory_config_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_app_observatory_config_proto_rawDesc,
 			NumEnums:      0,
-			NumMessages:   5,
+			NumMessages:   6,
 			NumExtensions: 0,
 			NumServices:   0,
 		},

+ 11 - 0
app/observatory/config.proto

@@ -10,6 +10,15 @@ message ObservationResult {
   repeated OutboundStatus status = 1;
 }
 
+message HealthPingMeasurementResult {
+  int64 all = 1;
+  int64 fail = 2;
+  int64 deviation = 3;
+  int64 average = 4;
+  int64 max = 5;
+  int64 min = 6;
+}
+
 message OutboundStatus{
   /* @Document Whether this outbound is usable
      @Restriction ReadOnlyForUser
@@ -36,6 +45,8 @@ message OutboundStatus{
    @Type id.outboundTag
 */
   int64 last_try_time = 6;
+
+  HealthPingMeasurementResult health_ping = 7;
 }
 
 message ProbeResult{

+ 1 - 1
app/policy/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/policy/config.proto
 

+ 1 - 1
app/proxyman/command/command.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/proxyman/command/command.proto
 

+ 1 - 1
app/proxyman/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/proxyman/config.proto
 

+ 1 - 1
app/reverse/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/reverse/config.proto
 

+ 68 - 20
app/router/balancing.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	sync "sync"
 
-	"github.com/xtls/xray-core/common/dice"
 	"github.com/xtls/xray-core/features/extension"
 	"github.com/xtls/xray-core/features/outbound"
 )
@@ -13,15 +12,8 @@ type BalancingStrategy interface {
 	PickOutbound([]string) string
 }
 
-type RandomStrategy struct{}
-
-func (s *RandomStrategy) PickOutbound(tags []string) string {
-	n := len(tags)
-	if n == 0 {
-		panic("0 tags")
-	}
-
-	return tags[dice.Roll(n)]
+type BalancingPrincipleTarget interface {
+	GetPrincipleTarget([]string) []string
 }
 
 type RoundRobinStrategy struct {
@@ -43,22 +35,36 @@ func (s *RoundRobinStrategy) PickOutbound(tags []string) string {
 }
 
 type Balancer struct {
-	selectors []string
-	strategy  BalancingStrategy
-	ohm       outbound.Manager
+	selectors   []string
+	strategy    BalancingStrategy
+	ohm         outbound.Manager
+	fallbackTag string
+
+	override override
 }
 
+// PickOutbound picks the tag of a outbound
 func (b *Balancer) PickOutbound() (string, error) {
-	hs, ok := b.ohm.(outbound.HandlerSelector)
-	if !ok {
-		return "", newError("outbound.Manager is not a HandlerSelector")
+	candidates, err := b.SelectOutbounds()
+	if err != nil {
+		if b.fallbackTag != "" {
+			newError("fallback to [", b.fallbackTag, "], due to error: ", err).AtInfo().WriteToLog()
+			return b.fallbackTag, nil
+		}
+		return "", err
 	}
-	tags := hs.Select(b.selectors)
-	if len(tags) == 0 {
-		return "", newError("no available outbounds selected")
+	var tag string
+	if o := b.override.Get(); o != "" {
+		tag = o
+	} else {
+		tag = b.strategy.PickOutbound(candidates)
 	}
-	tag := b.strategy.PickOutbound(tags)
 	if tag == "" {
+		if b.fallbackTag != "" {
+			newError("fallback to [", b.fallbackTag, "], due to empty tag returned").AtInfo().WriteToLog()
+			return b.fallbackTag, nil
+		}
+		// will use default handler
 		return "", newError("balancing strategy returns empty tag")
 	}
 	return tag, nil
@@ -69,3 +75,45 @@ func (b *Balancer) InjectContext(ctx context.Context) {
 		contextReceiver.InjectContext(ctx)
 	}
 }
+
+// SelectOutbounds select outbounds with selectors of the Balancer
+func (b *Balancer) SelectOutbounds() ([]string, error) {
+	hs, ok := b.ohm.(outbound.HandlerSelector)
+	if !ok {
+		return nil, newError("outbound.Manager is not a HandlerSelector")
+	}
+	tags := hs.Select(b.selectors)
+	return tags, nil
+}
+
+// GetPrincipleTarget implements routing.BalancerPrincipleTarget
+func (r *Router) GetPrincipleTarget(tag string) ([]string, error) {
+	if b, ok := r.balancers[tag]; ok {
+		if s, ok := b.strategy.(BalancingPrincipleTarget); ok {
+			candidates, err := b.SelectOutbounds()
+			if err != nil {
+				return nil, newError("unable to select outbounds").Base(err)
+			}
+			return s.GetPrincipleTarget(candidates), nil
+		}
+		return nil, newError("unsupported GetPrincipleTarget")
+	}
+	return nil, newError("cannot find tag")
+}
+
+// SetOverrideTarget implements routing.BalancerOverrider
+func (r *Router) SetOverrideTarget(tag, target string) error {
+	if b, ok := r.balancers[tag]; ok {
+		b.override.Put(target)
+		return nil
+	}
+	return newError("cannot find tag")
+}
+
+// GetOverrideTarget implements routing.BalancerOverrider
+func (r *Router) GetOverrideTarget(tag string) (string, error) {
+	if b, ok := r.balancers[tag]; ok {
+		return b.override.Get(), nil
+	}
+	return "", newError("cannot find tag")
+}

+ 50 - 0
app/router/balancing_override.go

@@ -0,0 +1,50 @@
+package router
+
+import (
+	sync "sync"
+)
+
+func (r *Router) OverrideBalancer(balancer string, target string) error {
+	var b *Balancer
+	for tag, bl := range r.balancers {
+		if tag == balancer {
+			b = bl
+			break
+		}
+	}
+	if b == nil {
+		return newError("balancer '", balancer, "' not found")
+	}
+	b.override.Put(target)
+	return nil
+}
+
+type overrideSettings struct {
+	target string
+}
+
+type override struct {
+	access   sync.RWMutex
+	settings overrideSettings
+}
+
+// Get gets the override settings
+func (o *override) Get() string {
+	o.access.RLock()
+	defer o.access.RUnlock()
+	return o.settings.target
+}
+
+// Put updates the override settings
+func (o *override) Put(target string) {
+	o.access.Lock()
+	defer o.access.Unlock()
+	o.settings.target = target
+}
+
+// Clear clears the override settings
+func (o *override) Clear() {
+	o.access.Lock()
+	defer o.access.Unlock()
+	o.settings.target = ""
+}

+ 35 - 0
app/router/command/command.go

@@ -19,6 +19,41 @@ type routingServer struct {
 	routingStats stats.Channel
 }
 
+func (s *routingServer) GetBalancerInfo(ctx context.Context, request *GetBalancerInfoRequest) (*GetBalancerInfoResponse, error) {
+	var ret GetBalancerInfoResponse
+	ret.Balancer = &BalancerMsg{}
+	if bo, ok := s.router.(routing.BalancerOverrider); ok {
+		{
+			res, err := bo.GetOverrideTarget(request.GetTag())
+			if err != nil {
+				return nil, err
+			}
+			ret.Balancer.Override = &OverrideInfo{
+				Target: res,
+			}
+		}
+	}
+
+	if pt, ok := s.router.(routing.BalancerPrincipleTarget); ok {
+		{
+			res, err := pt.GetPrincipleTarget(request.GetTag())
+			if err != nil {
+				newError("unable to obtain principle target").Base(err).AtInfo().WriteToLog()
+			} else {
+				ret.Balancer.PrincipleTarget = &PrincipleTargetInfo{Tag: res}
+			}
+		}
+	}
+	return &ret, nil
+}
+
+func (s *routingServer) OverrideBalancerTarget(ctx context.Context, request *OverrideBalancerTargetRequest) (*OverrideBalancerTargetResponse, error) {
+	if bo, ok := s.router.(routing.BalancerOverrider); ok {
+		return &OverrideBalancerTargetResponse{}, bo.SetOverrideTarget(request.BalancerTag, request.Target)
+	}
+	return nil, newError("unsupported router implementation")
+}
+
 // NewRoutingServer creates a statistics service with statistics manager.
 func NewRoutingServer(router routing.Router, routingStats stats.Channel) RoutingServiceServer {
 	return &routingServer{

+ 529 - 46
app/router/command/command.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/router/command/command.proto
 
@@ -294,6 +294,342 @@ func (x *TestRouteRequest) GetPublishResult() bool {
 	return false
 }
 
+type PrincipleTargetInfo struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Tag []string `protobuf:"bytes,1,rep,name=tag,proto3" json:"tag,omitempty"`
+}
+
+func (x *PrincipleTargetInfo) Reset() {
+	*x = PrincipleTargetInfo{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PrincipleTargetInfo) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PrincipleTargetInfo) ProtoMessage() {}
+
+func (x *PrincipleTargetInfo) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_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 PrincipleTargetInfo.ProtoReflect.Descriptor instead.
+func (*PrincipleTargetInfo) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *PrincipleTargetInfo) GetTag() []string {
+	if x != nil {
+		return x.Tag
+	}
+	return nil
+}
+
+type OverrideInfo struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"`
+}
+
+func (x *OverrideInfo) Reset() {
+	*x = OverrideInfo{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *OverrideInfo) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*OverrideInfo) ProtoMessage() {}
+
+func (x *OverrideInfo) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_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 OverrideInfo.ProtoReflect.Descriptor instead.
+func (*OverrideInfo) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *OverrideInfo) GetTarget() string {
+	if x != nil {
+		return x.Target
+	}
+	return ""
+}
+
+type BalancerMsg struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Override        *OverrideInfo        `protobuf:"bytes,5,opt,name=override,proto3" json:"override,omitempty"`
+	PrincipleTarget *PrincipleTargetInfo `protobuf:"bytes,6,opt,name=principle_target,json=principleTarget,proto3" json:"principle_target,omitempty"`
+}
+
+func (x *BalancerMsg) Reset() {
+	*x = BalancerMsg{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BalancerMsg) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BalancerMsg) ProtoMessage() {}
+
+func (x *BalancerMsg) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_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 BalancerMsg.ProtoReflect.Descriptor instead.
+func (*BalancerMsg) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *BalancerMsg) GetOverride() *OverrideInfo {
+	if x != nil {
+		return x.Override
+	}
+	return nil
+}
+
+func (x *BalancerMsg) GetPrincipleTarget() *PrincipleTargetInfo {
+	if x != nil {
+		return x.PrincipleTarget
+	}
+	return nil
+}
+
+type GetBalancerInfoRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
+}
+
+func (x *GetBalancerInfoRequest) Reset() {
+	*x = GetBalancerInfoRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetBalancerInfoRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetBalancerInfoRequest) ProtoMessage() {}
+
+func (x *GetBalancerInfoRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_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 GetBalancerInfoRequest.ProtoReflect.Descriptor instead.
+func (*GetBalancerInfoRequest) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *GetBalancerInfoRequest) GetTag() string {
+	if x != nil {
+		return x.Tag
+	}
+	return ""
+}
+
+type GetBalancerInfoResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Balancer *BalancerMsg `protobuf:"bytes,1,opt,name=balancer,proto3" json:"balancer,omitempty"`
+}
+
+func (x *GetBalancerInfoResponse) Reset() {
+	*x = GetBalancerInfoResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetBalancerInfoResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetBalancerInfoResponse) ProtoMessage() {}
+
+func (x *GetBalancerInfoResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_proto_msgTypes[7]
+	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 GetBalancerInfoResponse.ProtoReflect.Descriptor instead.
+func (*GetBalancerInfoResponse) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *GetBalancerInfoResponse) GetBalancer() *BalancerMsg {
+	if x != nil {
+		return x.Balancer
+	}
+	return nil
+}
+
+type OverrideBalancerTargetRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	BalancerTag string `protobuf:"bytes,1,opt,name=balancerTag,proto3" json:"balancerTag,omitempty"`
+	Target      string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"`
+}
+
+func (x *OverrideBalancerTargetRequest) Reset() {
+	*x = OverrideBalancerTargetRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *OverrideBalancerTargetRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*OverrideBalancerTargetRequest) ProtoMessage() {}
+
+func (x *OverrideBalancerTargetRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_proto_msgTypes[8]
+	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 OverrideBalancerTargetRequest.ProtoReflect.Descriptor instead.
+func (*OverrideBalancerTargetRequest) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *OverrideBalancerTargetRequest) GetBalancerTag() string {
+	if x != nil {
+		return x.BalancerTag
+	}
+	return ""
+}
+
+func (x *OverrideBalancerTargetRequest) GetTarget() string {
+	if x != nil {
+		return x.Target
+	}
+	return ""
+}
+
+type OverrideBalancerTargetResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *OverrideBalancerTargetResponse) Reset() {
+	*x = OverrideBalancerTargetResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_command_command_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *OverrideBalancerTargetResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*OverrideBalancerTargetResponse) ProtoMessage() {}
+
+func (x *OverrideBalancerTargetResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_command_command_proto_msgTypes[9]
+	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 OverrideBalancerTargetResponse.ProtoReflect.Descriptor instead.
+func (*OverrideBalancerTargetResponse) Descriptor() ([]byte, []int) {
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{9}
+}
+
 type Config struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -303,7 +639,7 @@ type Config struct {
 func (x *Config) Reset() {
 	*x = Config{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_router_command_command_proto_msgTypes[3]
+		mi := &file_app_router_command_command_proto_msgTypes[10]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -316,7 +652,7 @@ func (x *Config) String() string {
 func (*Config) ProtoMessage() {}
 
 func (x *Config) ProtoReflect() protoreflect.Message {
-	mi := &file_app_router_command_command_proto_msgTypes[3]
+	mi := &file_app_router_command_command_proto_msgTypes[10]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -329,7 +665,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Config.ProtoReflect.Descriptor instead.
 func (*Config) Descriptor() ([]byte, []int) {
-	return file_app_router_command_command_proto_rawDescGZIP(), []int{3}
+	return file_app_router_command_command_proto_rawDescGZIP(), []int{10}
 }
 
 var File_app_router_command_command_proto protoreflect.FileDescriptor
@@ -390,29 +726,78 @@ var file_app_router_command_command_proto_rawDesc = []byte{
 	0x64, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x50, 0x75,
 	0x62, 0x6c, 0x69, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28,
 	0x08, 0x52, 0x0d, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
-	0x22, 0x08, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x32, 0xf0, 0x01, 0x0a, 0x0e, 0x52,
-	0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x7b, 0x0a,
-	0x15, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e,
-	0x67, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x35, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70,
+	0x22, 0x27, 0x0a, 0x13, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x6c, 0x65, 0x54, 0x61, 0x72,
+	0x67, 0x65, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x01,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, 0x22, 0x26, 0x0a, 0x0c, 0x4f, 0x76, 0x65,
+	0x72, 0x72, 0x69, 0x64, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72,
+	0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65,
+	0x74, 0x22, 0xa9, 0x01, 0x0a, 0x0b, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x4d, 0x73,
+	0x67, 0x12, 0x41, 0x0a, 0x08, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x4f, 0x76,
+	0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x6f, 0x76, 0x65, 0x72,
+	0x72, 0x69, 0x64, 0x65, 0x12, 0x57, 0x0a, 0x10, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x6c,
+	0x65, 0x5f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c,
+	0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72,
+	0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70,
+	0x6c, 0x65, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x70, 0x72,
+	0x69, 0x6e, 0x63, 0x69, 0x70, 0x6c, 0x65, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x22, 0x2a, 0x0a,
+	0x16, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, 0x22, 0x5b, 0x0a, 0x17, 0x47, 0x65, 0x74,
+	0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x08, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70,
 	0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64,
-	0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e,
-	0x67, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e,
-	0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e,
-	0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x43,
-	0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x00, 0x30, 0x01, 0x12, 0x61, 0x0a, 0x09, 0x54, 0x65,
-	0x73, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61,
-	0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
-	0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x1a, 0x27, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x52, 0x6f, 0x75,
-	0x74, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x00, 0x42, 0x67, 0x0a,
-	0x1b, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x50, 0x01, 0x5a, 0x2c,
-	0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f,
-	0x78, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0xaa, 0x02, 0x17, 0x58,
-	0x72, 0x61, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43,
-	0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x2e, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x4d, 0x73, 0x67, 0x52, 0x08, 0x62, 0x61,
+	0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x22, 0x59, 0x0a, 0x1d, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69,
+	0x64, 0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x62, 0x61, 0x6c, 0x61, 0x6e,
+	0x63, 0x65, 0x72, 0x54, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x62, 0x61,
+	0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72,
+	0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65,
+	0x74, 0x22, 0x20, 0x0a, 0x1e, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x42, 0x61, 0x6c,
+	0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x08, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x32, 0xf6, 0x03,
+	0x0a, 0x0e, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+	0x12, 0x7b, 0x0a, 0x15, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x6f, 0x75,
+	0x74, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x35, 0x2e, 0x78, 0x72, 0x61, 0x79,
+	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d,
+	0x61, 0x6e, 0x64, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x6f, 0x75,
+	0x74, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x27, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74,
+	0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x69,
+	0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x00, 0x30, 0x01, 0x12, 0x61, 0x0a,
+	0x09, 0x54, 0x65, 0x73, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x78, 0x72, 0x61,
+	0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d,
+	0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70,
+	0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e,
+	0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0x00,
+	0x12, 0x76, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x49,
+	0x6e, 0x66, 0x6f, 0x12, 0x2f, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x47, 0x65,
+	0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e,
+	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x47,
+	0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x8b, 0x01, 0x0a, 0x16, 0x4f, 0x76, 0x65,
+	0x72, 0x72, 0x69, 0x64, 0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61, 0x72,
+	0x67, 0x65, 0x74, 0x12, 0x36, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x4f, 0x76,
+	0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61,
+	0x72, 0x67, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x37, 0x2e, 0x78, 0x72,
+	0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f,
+	0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x42, 0x61,
+	0x6c, 0x61, 0x6e, 0x63, 0x65, 0x72, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x67, 0x0a, 0x1b, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72,
+	0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x63, 0x6f,
+	0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x50, 0x01, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f,
+	0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2f, 0x63, 0x6f,
+	0x6d, 0x6d, 0x61, 0x6e, 0x64, 0xaa, 0x02, 0x17, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x41, 0x70, 0x70,
+	0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x62,
+	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -427,28 +812,42 @@ func file_app_router_command_command_proto_rawDescGZIP() []byte {
 	return file_app_router_command_command_proto_rawDescData
 }
 
-var file_app_router_command_command_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
+var file_app_router_command_command_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
 var file_app_router_command_command_proto_goTypes = []interface{}{
-	(*RoutingContext)(nil),               // 0: xray.app.router.command.RoutingContext
-	(*SubscribeRoutingStatsRequest)(nil), // 1: xray.app.router.command.SubscribeRoutingStatsRequest
-	(*TestRouteRequest)(nil),             // 2: xray.app.router.command.TestRouteRequest
-	(*Config)(nil),                       // 3: xray.app.router.command.Config
-	nil,                                  // 4: xray.app.router.command.RoutingContext.AttributesEntry
-	(net.Network)(0),                     // 5: xray.common.net.Network
+	(*RoutingContext)(nil),                 // 0: xray.app.router.command.RoutingContext
+	(*SubscribeRoutingStatsRequest)(nil),   // 1: xray.app.router.command.SubscribeRoutingStatsRequest
+	(*TestRouteRequest)(nil),               // 2: xray.app.router.command.TestRouteRequest
+	(*PrincipleTargetInfo)(nil),            // 3: xray.app.router.command.PrincipleTargetInfo
+	(*OverrideInfo)(nil),                   // 4: xray.app.router.command.OverrideInfo
+	(*BalancerMsg)(nil),                    // 5: xray.app.router.command.BalancerMsg
+	(*GetBalancerInfoRequest)(nil),         // 6: xray.app.router.command.GetBalancerInfoRequest
+	(*GetBalancerInfoResponse)(nil),        // 7: xray.app.router.command.GetBalancerInfoResponse
+	(*OverrideBalancerTargetRequest)(nil),  // 8: xray.app.router.command.OverrideBalancerTargetRequest
+	(*OverrideBalancerTargetResponse)(nil), // 9: xray.app.router.command.OverrideBalancerTargetResponse
+	(*Config)(nil),                         // 10: xray.app.router.command.Config
+	nil,                                    // 11: xray.app.router.command.RoutingContext.AttributesEntry
+	(net.Network)(0),                       // 12: xray.common.net.Network
 }
 var file_app_router_command_command_proto_depIdxs = []int32{
-	5, // 0: xray.app.router.command.RoutingContext.Network:type_name -> xray.common.net.Network
-	4, // 1: xray.app.router.command.RoutingContext.Attributes:type_name -> xray.app.router.command.RoutingContext.AttributesEntry
-	0, // 2: xray.app.router.command.TestRouteRequest.RoutingContext:type_name -> xray.app.router.command.RoutingContext
-	1, // 3: xray.app.router.command.RoutingService.SubscribeRoutingStats:input_type -> xray.app.router.command.SubscribeRoutingStatsRequest
-	2, // 4: xray.app.router.command.RoutingService.TestRoute:input_type -> xray.app.router.command.TestRouteRequest
-	0, // 5: xray.app.router.command.RoutingService.SubscribeRoutingStats:output_type -> xray.app.router.command.RoutingContext
-	0, // 6: xray.app.router.command.RoutingService.TestRoute:output_type -> xray.app.router.command.RoutingContext
-	5, // [5:7] is the sub-list for method output_type
-	3, // [3:5] is the sub-list for method input_type
-	3, // [3:3] is the sub-list for extension type_name
-	3, // [3:3] is the sub-list for extension extendee
-	0, // [0:3] is the sub-list for field type_name
+	12, // 0: xray.app.router.command.RoutingContext.Network:type_name -> xray.common.net.Network
+	11, // 1: xray.app.router.command.RoutingContext.Attributes:type_name -> xray.app.router.command.RoutingContext.AttributesEntry
+	0,  // 2: xray.app.router.command.TestRouteRequest.RoutingContext:type_name -> xray.app.router.command.RoutingContext
+	4,  // 3: xray.app.router.command.BalancerMsg.override:type_name -> xray.app.router.command.OverrideInfo
+	3,  // 4: xray.app.router.command.BalancerMsg.principle_target:type_name -> xray.app.router.command.PrincipleTargetInfo
+	5,  // 5: xray.app.router.command.GetBalancerInfoResponse.balancer:type_name -> xray.app.router.command.BalancerMsg
+	1,  // 6: xray.app.router.command.RoutingService.SubscribeRoutingStats:input_type -> xray.app.router.command.SubscribeRoutingStatsRequest
+	2,  // 7: xray.app.router.command.RoutingService.TestRoute:input_type -> xray.app.router.command.TestRouteRequest
+	6,  // 8: xray.app.router.command.RoutingService.GetBalancerInfo:input_type -> xray.app.router.command.GetBalancerInfoRequest
+	8,  // 9: xray.app.router.command.RoutingService.OverrideBalancerTarget:input_type -> xray.app.router.command.OverrideBalancerTargetRequest
+	0,  // 10: xray.app.router.command.RoutingService.SubscribeRoutingStats:output_type -> xray.app.router.command.RoutingContext
+	0,  // 11: xray.app.router.command.RoutingService.TestRoute:output_type -> xray.app.router.command.RoutingContext
+	7,  // 12: xray.app.router.command.RoutingService.GetBalancerInfo:output_type -> xray.app.router.command.GetBalancerInfoResponse
+	9,  // 13: xray.app.router.command.RoutingService.OverrideBalancerTarget:output_type -> xray.app.router.command.OverrideBalancerTargetResponse
+	10, // [10:14] is the sub-list for method output_type
+	6,  // [6:10] is the sub-list for method input_type
+	6,  // [6:6] is the sub-list for extension type_name
+	6,  // [6:6] is the sub-list for extension extendee
+	0,  // [0:6] is the sub-list for field type_name
 }
 
 func init() { file_app_router_command_command_proto_init() }
@@ -494,6 +893,90 @@ func file_app_router_command_command_proto_init() {
 			}
 		}
 		file_app_router_command_command_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PrincipleTargetInfo); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*OverrideInfo); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BalancerMsg); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetBalancerInfoRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetBalancerInfoResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*OverrideBalancerTargetRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*OverrideBalancerTargetResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_command_command_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*Config); i {
 			case 0:
 				return &v.state
@@ -512,7 +995,7 @@ func file_app_router_command_command_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_app_router_command_command_proto_rawDesc,
 			NumEnums:      0,
-			NumMessages:   5,
+			NumMessages:   12,
 			NumExtensions: 0,
 			NumServices:   1,
 		},

+ 31 - 0
app/router/command/command.proto

@@ -60,10 +60,41 @@ message TestRouteRequest {
   bool PublishResult = 3;
 }
 
+message PrincipleTargetInfo {
+  repeated string tag = 1;
+}
+
+message OverrideInfo {
+  string target = 2;
+}
+
+message BalancerMsg {
+  OverrideInfo override = 5;
+  PrincipleTargetInfo principle_target = 6;
+}
+
+message GetBalancerInfoRequest {
+  string tag = 1;
+}
+
+message GetBalancerInfoResponse {
+  BalancerMsg balancer = 1;
+}
+
+message OverrideBalancerTargetRequest {
+  string balancerTag = 1;
+  string target = 2;
+}
+
+message OverrideBalancerTargetResponse {}
+
 service RoutingService {
   rpc SubscribeRoutingStats(SubscribeRoutingStatsRequest)
       returns (stream RoutingContext) {}
   rpc TestRoute(TestRouteRequest) returns (RoutingContext) {}
+
+  rpc GetBalancerInfo(GetBalancerInfoRequest) returns (GetBalancerInfoResponse){}
+  rpc OverrideBalancerTarget(OverrideBalancerTargetRequest) returns (OverrideBalancerTargetResponse) {}
 }
 
 message Config {}

+ 76 - 2
app/router/command/command_grpc.pb.go

@@ -19,8 +19,10 @@ import (
 const _ = grpc.SupportPackageIsVersion7
 
 const (
-	RoutingService_SubscribeRoutingStats_FullMethodName = "/xray.app.router.command.RoutingService/SubscribeRoutingStats"
-	RoutingService_TestRoute_FullMethodName             = "/xray.app.router.command.RoutingService/TestRoute"
+	RoutingService_SubscribeRoutingStats_FullMethodName  = "/xray.app.router.command.RoutingService/SubscribeRoutingStats"
+	RoutingService_TestRoute_FullMethodName              = "/xray.app.router.command.RoutingService/TestRoute"
+	RoutingService_GetBalancerInfo_FullMethodName        = "/xray.app.router.command.RoutingService/GetBalancerInfo"
+	RoutingService_OverrideBalancerTarget_FullMethodName = "/xray.app.router.command.RoutingService/OverrideBalancerTarget"
 )
 
 // RoutingServiceClient is the client API for RoutingService service.
@@ -29,6 +31,8 @@ const (
 type RoutingServiceClient interface {
 	SubscribeRoutingStats(ctx context.Context, in *SubscribeRoutingStatsRequest, opts ...grpc.CallOption) (RoutingService_SubscribeRoutingStatsClient, error)
 	TestRoute(ctx context.Context, in *TestRouteRequest, opts ...grpc.CallOption) (*RoutingContext, error)
+	GetBalancerInfo(ctx context.Context, in *GetBalancerInfoRequest, opts ...grpc.CallOption) (*GetBalancerInfoResponse, error)
+	OverrideBalancerTarget(ctx context.Context, in *OverrideBalancerTargetRequest, opts ...grpc.CallOption) (*OverrideBalancerTargetResponse, error)
 }
 
 type routingServiceClient struct {
@@ -80,12 +84,32 @@ func (c *routingServiceClient) TestRoute(ctx context.Context, in *TestRouteReque
 	return out, nil
 }
 
+func (c *routingServiceClient) GetBalancerInfo(ctx context.Context, in *GetBalancerInfoRequest, opts ...grpc.CallOption) (*GetBalancerInfoResponse, error) {
+	out := new(GetBalancerInfoResponse)
+	err := c.cc.Invoke(ctx, RoutingService_GetBalancerInfo_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *routingServiceClient) OverrideBalancerTarget(ctx context.Context, in *OverrideBalancerTargetRequest, opts ...grpc.CallOption) (*OverrideBalancerTargetResponse, error) {
+	out := new(OverrideBalancerTargetResponse)
+	err := c.cc.Invoke(ctx, RoutingService_OverrideBalancerTarget_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
 // RoutingServiceServer is the server API for RoutingService service.
 // All implementations must embed UnimplementedRoutingServiceServer
 // for forward compatibility
 type RoutingServiceServer interface {
 	SubscribeRoutingStats(*SubscribeRoutingStatsRequest, RoutingService_SubscribeRoutingStatsServer) error
 	TestRoute(context.Context, *TestRouteRequest) (*RoutingContext, error)
+	GetBalancerInfo(context.Context, *GetBalancerInfoRequest) (*GetBalancerInfoResponse, error)
+	OverrideBalancerTarget(context.Context, *OverrideBalancerTargetRequest) (*OverrideBalancerTargetResponse, error)
 	mustEmbedUnimplementedRoutingServiceServer()
 }
 
@@ -99,6 +123,12 @@ func (UnimplementedRoutingServiceServer) SubscribeRoutingStats(*SubscribeRouting
 func (UnimplementedRoutingServiceServer) TestRoute(context.Context, *TestRouteRequest) (*RoutingContext, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method TestRoute not implemented")
 }
+func (UnimplementedRoutingServiceServer) GetBalancerInfo(context.Context, *GetBalancerInfoRequest) (*GetBalancerInfoResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetBalancerInfo not implemented")
+}
+func (UnimplementedRoutingServiceServer) OverrideBalancerTarget(context.Context, *OverrideBalancerTargetRequest) (*OverrideBalancerTargetResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method OverrideBalancerTarget not implemented")
+}
 func (UnimplementedRoutingServiceServer) mustEmbedUnimplementedRoutingServiceServer() {}
 
 // UnsafeRoutingServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -151,6 +181,42 @@ func _RoutingService_TestRoute_Handler(srv interface{}, ctx context.Context, dec
 	return interceptor(ctx, in, info, handler)
 }
 
+func _RoutingService_GetBalancerInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetBalancerInfoRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RoutingServiceServer).GetBalancerInfo(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: RoutingService_GetBalancerInfo_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RoutingServiceServer).GetBalancerInfo(ctx, req.(*GetBalancerInfoRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _RoutingService_OverrideBalancerTarget_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(OverrideBalancerTargetRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RoutingServiceServer).OverrideBalancerTarget(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: RoutingService_OverrideBalancerTarget_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RoutingServiceServer).OverrideBalancerTarget(ctx, req.(*OverrideBalancerTargetRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
 // RoutingService_ServiceDesc is the grpc.ServiceDesc for RoutingService service.
 // It's only intended for direct use with grpc.RegisterService,
 // and not to be introspected or modified (even as a copy)
@@ -162,6 +228,14 @@ var RoutingService_ServiceDesc = grpc.ServiceDesc{
 			MethodName: "TestRoute",
 			Handler:    _RoutingService_TestRoute_Handler,
 		},
+		{
+			MethodName: "GetBalancerInfo",
+			Handler:    _RoutingService_GetBalancerInfo_Handler,
+		},
+		{
+			MethodName: "OverrideBalancerTarget",
+			Handler:    _RoutingService_OverrideBalancerTarget_Handler,
+		},
 	},
 	Streams: []grpc.StreamDesc{
 		{

+ 1 - 1
app/router/command/command_test.go

@@ -318,7 +318,7 @@ func TestSerivceTestRoute(t *testing.T) {
 				TargetTag: &router.RoutingRule_Tag{Tag: "out"},
 			},
 		},
-	}, mocks.NewDNSClient(mockCtl), mocks.NewOutboundManager(mockCtl)))
+	}, mocks.NewDNSClient(mockCtl), mocks.NewOutboundManager(mockCtl), nil))
 
 	lis := bufconn.Listen(1024 * 1024)
 	bufDialer := func(context.Context, string) (net.Conn, error) {

+ 25 - 8
app/router/config.go

@@ -121,28 +121,45 @@ func (rr *RoutingRule) BuildCondition() (Condition, error) {
 	return conds, nil
 }
 
-func (br *BalancingRule) Build(ohm outbound.Manager) (*Balancer, error) {
-	switch br.Strategy {
-	case "leastPing":
+// Build builds the balancing rule
+func (br *BalancingRule) Build(ohm outbound.Manager, dispatcher routing.Dispatcher) (*Balancer, error) {
+	switch strings.ToLower(br.Strategy) {
+	case "leastping":
 		return &Balancer{
 			selectors: br.OutboundSelector,
 			strategy:  &LeastPingStrategy{},
 			ohm:       ohm,
 		}, nil
-	case "roundRobin":
+	case "roundrobin":
 		return &Balancer{
 			selectors: br.OutboundSelector,
 			strategy:  &RoundRobinStrategy{},
 			ohm:       ohm,
 		}, nil
+	case "leastload":
+		i, err := br.StrategySettings.GetInstance()
+		if err != nil {
+			return nil, err
+		}
+		s, ok := i.(*StrategyLeastLoadConfig)
+		if !ok {
+			return nil, newError("not a StrategyLeastLoadConfig").AtError()
+		}
+		leastLoadStrategy := NewLeastLoadStrategy(s)
+		return &Balancer{
+			selectors: br.OutboundSelector,
+			ohm:       ohm, fallbackTag: br.FallbackTag,
+			strategy: leastLoadStrategy,
+		}, nil
 	case "random":
 		fallthrough
-	default:
+	case "":
 		return &Balancer{
 			selectors: br.OutboundSelector,
-			strategy:  &RandomStrategy{},
-			ohm:       ohm,
+			ohm:       ohm, fallbackTag: br.FallbackTag,
+			strategy: &RandomStrategy{},
 		}, nil
-
+	default:
+		return nil, newError("unrecognized balancer type")
 	}
 }

+ 401 - 181
app/router/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/router/config.proto
 
@@ -8,6 +8,7 @@ package router
 
 import (
 	net "github.com/xtls/xray-core/common/net"
+	serial "github.com/xtls/xray-core/common/serial"
 	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
 	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
 	reflect "reflect"
@@ -131,7 +132,7 @@ func (x Config_DomainStrategy) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Config_DomainStrategy.Descriptor instead.
 func (Config_DomainStrategy) EnumDescriptor() ([]byte, []int) {
-	return file_app_router_config_proto_rawDescGZIP(), []int{8, 0}
+	return file_app_router_config_proto_rawDescGZIP(), []int{10, 0}
 }
 
 // Domain for routing decision.
@@ -707,9 +708,11 @@ type BalancingRule struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	Tag              string   `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
-	OutboundSelector []string `protobuf:"bytes,2,rep,name=outbound_selector,json=outboundSelector,proto3" json:"outbound_selector,omitempty"`
-	Strategy         string   `protobuf:"bytes,3,opt,name=strategy,proto3" json:"strategy,omitempty"`
+	Tag              string               `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"`
+	OutboundSelector []string             `protobuf:"bytes,2,rep,name=outbound_selector,json=outboundSelector,proto3" json:"outbound_selector,omitempty"`
+	Strategy         string               `protobuf:"bytes,3,opt,name=strategy,proto3" json:"strategy,omitempty"`
+	StrategySettings *serial.TypedMessage `protobuf:"bytes,4,opt,name=strategy_settings,json=strategySettings,proto3" json:"strategy_settings,omitempty"`
+	FallbackTag      string               `protobuf:"bytes,5,opt,name=fallback_tag,json=fallbackTag,proto3" json:"fallback_tag,omitempty"`
 }
 
 func (x *BalancingRule) Reset() {
@@ -765,6 +768,167 @@ func (x *BalancingRule) GetStrategy() string {
 	return ""
 }
 
+func (x *BalancingRule) GetStrategySettings() *serial.TypedMessage {
+	if x != nil {
+		return x.StrategySettings
+	}
+	return nil
+}
+
+func (x *BalancingRule) GetFallbackTag() string {
+	if x != nil {
+		return x.FallbackTag
+	}
+	return ""
+}
+
+type StrategyWeight struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Regexp bool    `protobuf:"varint,1,opt,name=regexp,proto3" json:"regexp,omitempty"`
+	Match  string  `protobuf:"bytes,2,opt,name=match,proto3" json:"match,omitempty"`
+	Value  float32 `protobuf:"fixed32,3,opt,name=value,proto3" json:"value,omitempty"`
+}
+
+func (x *StrategyWeight) Reset() {
+	*x = StrategyWeight{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_config_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StrategyWeight) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StrategyWeight) ProtoMessage() {}
+
+func (x *StrategyWeight) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_config_proto_msgTypes[8]
+	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 StrategyWeight.ProtoReflect.Descriptor instead.
+func (*StrategyWeight) Descriptor() ([]byte, []int) {
+	return file_app_router_config_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *StrategyWeight) GetRegexp() bool {
+	if x != nil {
+		return x.Regexp
+	}
+	return false
+}
+
+func (x *StrategyWeight) GetMatch() string {
+	if x != nil {
+		return x.Match
+	}
+	return ""
+}
+
+func (x *StrategyWeight) GetValue() float32 {
+	if x != nil {
+		return x.Value
+	}
+	return 0
+}
+
+type StrategyLeastLoadConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// weight settings
+	Costs []*StrategyWeight `protobuf:"bytes,2,rep,name=costs,proto3" json:"costs,omitempty"`
+	// RTT baselines for selecting, int64 values of time.Duration
+	Baselines []int64 `protobuf:"varint,3,rep,packed,name=baselines,proto3" json:"baselines,omitempty"`
+	// expected nodes count to select
+	Expected int32 `protobuf:"varint,4,opt,name=expected,proto3" json:"expected,omitempty"`
+	// max acceptable rtt, filter away high delay nodes. defalut 0
+	MaxRTT int64 `protobuf:"varint,5,opt,name=maxRTT,proto3" json:"maxRTT,omitempty"`
+	// acceptable failure rate
+	Tolerance float32 `protobuf:"fixed32,6,opt,name=tolerance,proto3" json:"tolerance,omitempty"`
+}
+
+func (x *StrategyLeastLoadConfig) Reset() {
+	*x = StrategyLeastLoadConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_app_router_config_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StrategyLeastLoadConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StrategyLeastLoadConfig) ProtoMessage() {}
+
+func (x *StrategyLeastLoadConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_app_router_config_proto_msgTypes[9]
+	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 StrategyLeastLoadConfig.ProtoReflect.Descriptor instead.
+func (*StrategyLeastLoadConfig) Descriptor() ([]byte, []int) {
+	return file_app_router_config_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *StrategyLeastLoadConfig) GetCosts() []*StrategyWeight {
+	if x != nil {
+		return x.Costs
+	}
+	return nil
+}
+
+func (x *StrategyLeastLoadConfig) GetBaselines() []int64 {
+	if x != nil {
+		return x.Baselines
+	}
+	return nil
+}
+
+func (x *StrategyLeastLoadConfig) GetExpected() int32 {
+	if x != nil {
+		return x.Expected
+	}
+	return 0
+}
+
+func (x *StrategyLeastLoadConfig) GetMaxRTT() int64 {
+	if x != nil {
+		return x.MaxRTT
+	}
+	return 0
+}
+
+func (x *StrategyLeastLoadConfig) GetTolerance() float32 {
+	if x != nil {
+		return x.Tolerance
+	}
+	return 0
+}
+
 type Config struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -778,7 +942,7 @@ type Config struct {
 func (x *Config) Reset() {
 	*x = Config{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_router_config_proto_msgTypes[8]
+		mi := &file_app_router_config_proto_msgTypes[10]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -791,7 +955,7 @@ func (x *Config) String() string {
 func (*Config) ProtoMessage() {}
 
 func (x *Config) ProtoReflect() protoreflect.Message {
-	mi := &file_app_router_config_proto_msgTypes[8]
+	mi := &file_app_router_config_proto_msgTypes[10]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -804,7 +968,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Config.ProtoReflect.Descriptor instead.
 func (*Config) Descriptor() ([]byte, []int) {
-	return file_app_router_config_proto_rawDescGZIP(), []int{8}
+	return file_app_router_config_proto_rawDescGZIP(), []int{10}
 }
 
 func (x *Config) GetDomainStrategy() Config_DomainStrategy {
@@ -844,7 +1008,7 @@ type Domain_Attribute struct {
 func (x *Domain_Attribute) Reset() {
 	*x = Domain_Attribute{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_app_router_config_proto_msgTypes[9]
+		mi := &file_app_router_config_proto_msgTypes[11]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -857,7 +1021,7 @@ func (x *Domain_Attribute) String() string {
 func (*Domain_Attribute) ProtoMessage() {}
 
 func (x *Domain_Attribute) ProtoReflect() protoreflect.Message {
-	mi := &file_app_router_config_proto_msgTypes[9]
+	mi := &file_app_router_config_proto_msgTypes[11]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -922,142 +1086,169 @@ var File_app_router_config_proto protoreflect.FileDescriptor
 var file_app_router_config_proto_rawDesc = []byte{
 	0x0a, 0x17, 0x61, 0x70, 0x70, 0x2f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x6e,
 	0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x78, 0x72, 0x61, 0x79, 0x2e,
-	0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0x15, 0x63, 0x6f, 0x6d, 0x6d,
-	0x6f, 0x6e, 0x2f, 0x6e, 0x65, 0x74, 0x2f, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74,
-	0x6f, 0x1a, 0x18, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x6e, 0x65, 0x74, 0x2f, 0x6e, 0x65,
-	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb3, 0x02, 0x0a, 0x06,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x30, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e,
-	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x54, 0x79,
-	0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75,
-	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x3f,
-	0x0a, 0x09, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x21, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75,
-	0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x41, 0x74, 0x74, 0x72, 0x69,
-	0x62, 0x75, 0x74, 0x65, 0x52, 0x09, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x1a,
-	0x6c, 0x0a, 0x09, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03,
-	0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1f,
-	0x0a, 0x0a, 0x62, 0x6f, 0x6f, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x08, 0x48, 0x00, 0x52, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12,
-	0x1d, 0x0a, 0x09, 0x69, 0x6e, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x03, 0x48, 0x00, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x0d,
-	0x0a, 0x0b, 0x74, 0x79, 0x70, 0x65, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x32, 0x0a,
-	0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x6c, 0x61, 0x69, 0x6e, 0x10, 0x00,
-	0x12, 0x09, 0x0a, 0x05, 0x52, 0x65, 0x67, 0x65, 0x78, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x44,
-	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x75, 0x6c, 0x6c, 0x10,
-	0x03, 0x22, 0x2e, 0x0a, 0x04, 0x43, 0x49, 0x44, 0x52, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18,
-	0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65,
-	0x66, 0x69, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69,
-	0x78, 0x22, 0x7a, 0x0a, 0x05, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f,
-	0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x29, 0x0a,
-	0x04, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x78, 0x72,
-	0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x49,
-	0x44, 0x52, 0x52, 0x04, 0x63, 0x69, 0x64, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x76, 0x65,
-	0x72, 0x73, 0x65, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x0c, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x22, 0x39, 0x0a,
-	0x09, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x05, 0x65, 0x6e,
-	0x74, 0x72, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x78, 0x72, 0x61, 0x79,
-	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x49,
-	0x50, 0x52, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0x5d, 0x0a, 0x07, 0x47, 0x65, 0x6f, 0x53,
-	0x69, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63,
-	0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74,
-	0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
-	0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70,
-	0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52,
-	0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x3d, 0x0a, 0x0b, 0x47, 0x65, 0x6f, 0x53, 0x69,
-	0x74, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x18,
-	0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70,
-	0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x53, 0x69, 0x74, 0x65, 0x52,
-	0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xa2, 0x07, 0x0a, 0x0b, 0x52, 0x6f, 0x75, 0x74, 0x69,
-	0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x74, 0x61, 0x67, 0x12, 0x25, 0x0a, 0x0d, 0x62, 0x61,
-	0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28,
-	0x09, 0x48, 0x00, 0x52, 0x0c, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x54, 0x61,
-	0x67, 0x12, 0x2f, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x17, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75,
-	0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
-	0x69, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b,
-	0x32, 0x15, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74,
-	0x65, 0x72, 0x2e, 0x43, 0x49, 0x44, 0x52, 0x42, 0x02, 0x18, 0x01, 0x52, 0x04, 0x63, 0x69, 0x64,
-	0x72, 0x12, 0x2c, 0x0a, 0x05, 0x67, 0x65, 0x6f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b,
-	0x32, 0x16, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74,
-	0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x52, 0x05, 0x67, 0x65, 0x6f, 0x69, 0x70, 0x12,
-	0x3d, 0x0a, 0x0a, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x04, 0x20,
-	0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
-	0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x42,
-	0x02, 0x18, 0x01, 0x52, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x36,
-	0x0a, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x19, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e,
-	0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x08, 0x70, 0x6f,
-	0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x43, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
-	0x6b, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x78,
-	0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x4e,
-	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0b,
-	0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x08, 0x6e,
-	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x18, 0x2e,
-	0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e,
-	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
-	0x73, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x63, 0x69, 0x64, 0x72,
-	0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70,
-	0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x49, 0x44, 0x52, 0x42, 0x02, 0x18,
-	0x01, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x69, 0x64, 0x72, 0x12, 0x39, 0x0a,
-	0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x65, 0x6f, 0x69, 0x70, 0x18, 0x0b, 0x20,
+	0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0x21, 0x63, 0x6f, 0x6d, 0x6d,
+	0x6f, 0x6e, 0x2f, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x64, 0x5f,
+	0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x15, 0x63,
+	0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x6e, 0x65, 0x74, 0x2f, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x18, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x6e, 0x65, 0x74,
+	0x2f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb3,
+	0x02, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x30, 0x0a, 0x04, 0x74, 0x79, 0x70,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61,
+	0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
+	0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76,
+	0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
+	0x65, 0x12, 0x3f, 0x0a, 0x09, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x18, 0x03,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e,
+	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x41, 0x74,
+	0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x52, 0x09, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75,
+	0x74, 0x65, 0x1a, 0x6c, 0x0a, 0x09, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x12,
+	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+	0x79, 0x12, 0x1f, 0x0a, 0x0a, 0x62, 0x6f, 0x6f, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x09, 0x62, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x12, 0x1d, 0x0a, 0x09, 0x69, 0x6e, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x56, 0x61, 0x6c, 0x75,
+	0x65, 0x42, 0x0d, 0x0a, 0x0b, 0x74, 0x79, 0x70, 0x65, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x22, 0x32, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x6c, 0x61, 0x69,
+	0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x52, 0x65, 0x67, 0x65, 0x78, 0x10, 0x01, 0x12, 0x0a,
+	0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x75,
+	0x6c, 0x6c, 0x10, 0x03, 0x22, 0x2e, 0x0a, 0x04, 0x43, 0x49, 0x44, 0x52, 0x12, 0x0e, 0x0a, 0x02,
+	0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x12, 0x16, 0x0a, 0x06,
+	0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x70, 0x72,
+	0x65, 0x66, 0x69, 0x78, 0x22, 0x7a, 0x0a, 0x05, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x12, 0x21, 0x0a,
+	0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65,
+	0x12, 0x29, 0x0a, 0x04, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72,
+	0x2e, 0x43, 0x49, 0x44, 0x52, 0x52, 0x04, 0x63, 0x69, 0x64, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x72,
+	0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x0c, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x4d, 0x61, 0x74, 0x63, 0x68,
+	0x22, 0x39, 0x0a, 0x09, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2c, 0x0a,
+	0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x78,
+	0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47,
+	0x65, 0x6f, 0x49, 0x50, 0x52, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0x5d, 0x0a, 0x07, 0x47,
+	0x65, 0x6f, 0x53, 0x69, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
+	0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x64, 0x6f, 0x6d,
+	0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x78, 0x72, 0x61, 0x79,
+	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61,
+	0x69, 0x6e, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x3d, 0x0a, 0x0b, 0x47, 0x65,
+	0x6f, 0x53, 0x69, 0x74, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x05, 0x65, 0x6e, 0x74,
+	0x72, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e,
+	0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x53, 0x69,
+	0x74, 0x65, 0x52, 0x05, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xa2, 0x07, 0x0a, 0x0b, 0x52, 0x6f,
+	0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x03, 0x74, 0x61, 0x67,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x74, 0x61, 0x67, 0x12, 0x25, 0x0a,
+	0x0d, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x0c,
+	0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e,
+	0x67, 0x54, 0x61, 0x67, 0x12, 0x2f, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e,
+	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x52, 0x06, 0x64,
+	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x49, 0x44, 0x52, 0x42, 0x02, 0x18, 0x01, 0x52, 0x04,
+	0x63, 0x69, 0x64, 0x72, 0x12, 0x2c, 0x0a, 0x05, 0x67, 0x65, 0x6f, 0x69, 0x70, 0x18, 0x0a, 0x20,
 	0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72,
-	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x52, 0x0b, 0x73, 0x6f, 0x75,
-	0x72, 0x63, 0x65, 0x47, 0x65, 0x6f, 0x69, 0x70, 0x12, 0x43, 0x0a, 0x10, 0x73, 0x6f, 0x75, 0x72,
-	0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x10, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
-	0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x0e, 0x73,
-	0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x1d, 0x0a,
-	0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1f, 0x0a, 0x0b,
-	0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x08, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x0a, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x54, 0x61, 0x67, 0x12, 0x1a, 0x0a,
-	0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52,
-	0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x4c, 0x0a, 0x0a, 0x61, 0x74, 0x74,
-	0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e,
-	0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e,
-	0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x2e, 0x41, 0x74, 0x74, 0x72,
-	0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x61, 0x74, 0x74,
-	0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x6f, 0x6d, 0x61, 0x69,
-	0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x72, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0d, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x72, 0x1a, 0x3d,
-	0x0a, 0x0f, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72,
-	0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
-	0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0c, 0x0a,
-	0x0a, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x74, 0x61, 0x67, 0x22, 0x6a, 0x0a, 0x0d, 0x42,
-	0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x10, 0x0a, 0x03,
-	0x74, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, 0x12, 0x2b,
-	0x0a, 0x11, 0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x73, 0x65, 0x6c, 0x65, 0x63,
-	0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x6f, 0x75, 0x74, 0x62, 0x6f,
-	0x75, 0x6e, 0x64, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x73,
-	0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73,
-	0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x22, 0x9b, 0x02, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66,
-	0x69, 0x67, 0x12, 0x4f, 0x0a, 0x0f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x72,
-	0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x26, 0x2e, 0x78, 0x72,
-	0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74,
-	0x65, 0x67, 0x79, 0x52, 0x0e, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74,
-	0x65, 0x67, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x72, 0x75, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x1c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75,
-	0x74, 0x65, 0x72, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52,
-	0x04, 0x72, 0x75, 0x6c, 0x65, 0x12, 0x45, 0x0a, 0x0e, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69,
-	0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e,
-	0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e,
-	0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x62,
-	0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x22, 0x47, 0x0a, 0x0e,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x08,
-	0x0a, 0x04, 0x41, 0x73, 0x49, 0x73, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x55, 0x73, 0x65, 0x49,
-	0x70, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x70, 0x49, 0x66, 0x4e, 0x6f, 0x6e, 0x4d, 0x61,
-	0x74, 0x63, 0x68, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x49, 0x70, 0x4f, 0x6e, 0x44, 0x65, 0x6d,
-	0x61, 0x6e, 0x64, 0x10, 0x03, 0x42, 0x4f, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61,
-	0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x01, 0x5a, 0x24,
-	0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f,
-	0x78, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0xaa, 0x02, 0x0f, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x2e,
-	0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x52, 0x05, 0x67, 0x65, 0x6f,
+	0x69, 0x70, 0x12, 0x3d, 0x0a, 0x0a, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f,
+	0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e,
+	0x67, 0x65, 0x42, 0x02, 0x18, 0x01, 0x52, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67,
+	0x65, 0x12, 0x36, 0x0a, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x0e,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d,
+	0x6f, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x52,
+	0x08, 0x70, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x43, 0x0a, 0x0c, 0x6e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x6e, 0x65,
+	0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x02, 0x18,
+	0x01, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x34,
+	0x0a, 0x08, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0e,
+	0x32, 0x18, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x6e,
+	0x65, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x08, 0x6e, 0x65, 0x74, 0x77,
+	0x6f, 0x72, 0x6b, 0x73, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x63,
+	0x69, 0x64, 0x72, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x78, 0x72, 0x61, 0x79,
+	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x49, 0x44, 0x52,
+	0x42, 0x02, 0x18, 0x01, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x69, 0x64, 0x72,
+	0x12, 0x39, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x65, 0x6f, 0x69, 0x70,
+	0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70,
+	0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x52, 0x0b,
+	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x65, 0x6f, 0x69, 0x70, 0x12, 0x43, 0x0a, 0x10, 0x73,
+	0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x18,
+	0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f, 0x6d,
+	0x6d, 0x6f, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74,
+	0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x4c, 0x69, 0x73, 0x74,
+	0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12,
+	0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x08,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x54, 0x61, 0x67,
+	0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x09, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x4c, 0x0a, 0x0a,
+	0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x2c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74,
+	0x65, 0x72, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x2e, 0x41,
+	0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a,
+	0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x6f,
+	0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x72, 0x18, 0x11, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0d, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x65,
+	0x72, 0x1a, 0x3d, 0x0a, 0x0f, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x45,
+	0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
+	0x42, 0x0c, 0x0a, 0x0a, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x74, 0x61, 0x67, 0x22, 0xdc,
+	0x01, 0x0a, 0x0d, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65,
+	0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74,
+	0x61, 0x67, 0x12, 0x2b, 0x0a, 0x11, 0x6f, 0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x73,
+	0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x6f,
+	0x75, 0x74, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12,
+	0x1a, 0x0a, 0x08, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x4d, 0x0a, 0x11, 0x73,
+	0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x63, 0x6f,
+	0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x2e, 0x54, 0x79, 0x70, 0x65,
+	0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65,
+	0x67, 0x79, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x66, 0x61,
+	0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0b, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x54, 0x61, 0x67, 0x22, 0x54, 0x0a,
+	0x0e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x57, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12,
+	0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x65, 0x78, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x06, 0x72, 0x65, 0x67, 0x65, 0x78, 0x70, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x0a,
+	0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x22, 0xc0, 0x01, 0x0a, 0x17, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79,
+	0x4c, 0x65, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x61, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
+	0x35, 0x0a, 0x05, 0x63, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f,
+	0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72,
+	0x2e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x57, 0x65, 0x69, 0x67, 0x68, 0x74, 0x52,
+	0x05, 0x63, 0x6f, 0x73, 0x74, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x62, 0x61, 0x73, 0x65, 0x6c, 0x69,
+	0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x09, 0x62, 0x61, 0x73, 0x65, 0x6c,
+	0x69, 0x6e, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64,
+	0x12, 0x16, 0x0a, 0x06, 0x6d, 0x61, 0x78, 0x52, 0x54, 0x54, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x06, 0x6d, 0x61, 0x78, 0x52, 0x54, 0x54, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x6f, 0x6c, 0x65,
+	0x72, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x52, 0x09, 0x74, 0x6f, 0x6c,
+	0x65, 0x72, 0x61, 0x6e, 0x63, 0x65, 0x22, 0x9b, 0x02, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x12, 0x4f, 0x0a, 0x0f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x73, 0x74, 0x72, 0x61,
+	0x74, 0x65, 0x67, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x26, 0x2e, 0x78, 0x72, 0x61,
+	0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x2e, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65,
+	0x67, 0x79, 0x52, 0x0e, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65,
+	0x67, 0x79, 0x12, 0x30, 0x0a, 0x04, 0x72, 0x75, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x1c, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74,
+	0x65, 0x72, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x04,
+	0x72, 0x75, 0x6c, 0x65, 0x12, 0x45, 0x0a, 0x0e, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e,
+	0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x78,
+	0x72, 0x61, 0x79, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x2e, 0x42,
+	0x61, 0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x62, 0x61,
+	0x6c, 0x61, 0x6e, 0x63, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x22, 0x47, 0x0a, 0x0e, 0x44,
+	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x08, 0x0a,
+	0x04, 0x41, 0x73, 0x49, 0x73, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x55, 0x73, 0x65, 0x49, 0x70,
+	0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x70, 0x49, 0x66, 0x4e, 0x6f, 0x6e, 0x4d, 0x61, 0x74,
+	0x63, 0x68, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x49, 0x70, 0x4f, 0x6e, 0x44, 0x65, 0x6d, 0x61,
+	0x6e, 0x64, 0x10, 0x03, 0x42, 0x4f, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61, 0x79,
+	0x2e, 0x61, 0x70, 0x70, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x01, 0x5a, 0x24, 0x67,
+	0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78,
+	0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x72, 0x6f, 0x75,
+	0x74, 0x65, 0x72, 0xaa, 0x02, 0x0f, 0x58, 0x72, 0x61, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x52,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -1073,29 +1264,32 @@ func file_app_router_config_proto_rawDescGZIP() []byte {
 }
 
 var file_app_router_config_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
-var file_app_router_config_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
+var file_app_router_config_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
 var file_app_router_config_proto_goTypes = []interface{}{
-	(Domain_Type)(0),           // 0: xray.app.router.Domain.Type
-	(Config_DomainStrategy)(0), // 1: xray.app.router.Config.DomainStrategy
-	(*Domain)(nil),             // 2: xray.app.router.Domain
-	(*CIDR)(nil),               // 3: xray.app.router.CIDR
-	(*GeoIP)(nil),              // 4: xray.app.router.GeoIP
-	(*GeoIPList)(nil),          // 5: xray.app.router.GeoIPList
-	(*GeoSite)(nil),            // 6: xray.app.router.GeoSite
-	(*GeoSiteList)(nil),        // 7: xray.app.router.GeoSiteList
-	(*RoutingRule)(nil),        // 8: xray.app.router.RoutingRule
-	(*BalancingRule)(nil),      // 9: xray.app.router.BalancingRule
-	(*Config)(nil),             // 10: xray.app.router.Config
-	(*Domain_Attribute)(nil),   // 11: xray.app.router.Domain.Attribute
-	nil,                        // 12: xray.app.router.RoutingRule.AttributesEntry
-	(*net.PortRange)(nil),      // 13: xray.common.net.PortRange
-	(*net.PortList)(nil),       // 14: xray.common.net.PortList
-	(*net.NetworkList)(nil),    // 15: xray.common.net.NetworkList
-	(net.Network)(0),           // 16: xray.common.net.Network
+	(Domain_Type)(0),                // 0: xray.app.router.Domain.Type
+	(Config_DomainStrategy)(0),      // 1: xray.app.router.Config.DomainStrategy
+	(*Domain)(nil),                  // 2: xray.app.router.Domain
+	(*CIDR)(nil),                    // 3: xray.app.router.CIDR
+	(*GeoIP)(nil),                   // 4: xray.app.router.GeoIP
+	(*GeoIPList)(nil),               // 5: xray.app.router.GeoIPList
+	(*GeoSite)(nil),                 // 6: xray.app.router.GeoSite
+	(*GeoSiteList)(nil),             // 7: xray.app.router.GeoSiteList
+	(*RoutingRule)(nil),             // 8: xray.app.router.RoutingRule
+	(*BalancingRule)(nil),           // 9: xray.app.router.BalancingRule
+	(*StrategyWeight)(nil),          // 10: xray.app.router.StrategyWeight
+	(*StrategyLeastLoadConfig)(nil), // 11: xray.app.router.StrategyLeastLoadConfig
+	(*Config)(nil),                  // 12: xray.app.router.Config
+	(*Domain_Attribute)(nil),        // 13: xray.app.router.Domain.Attribute
+	nil,                             // 14: xray.app.router.RoutingRule.AttributesEntry
+	(*net.PortRange)(nil),           // 15: xray.common.net.PortRange
+	(*net.PortList)(nil),            // 16: xray.common.net.PortList
+	(*net.NetworkList)(nil),         // 17: xray.common.net.NetworkList
+	(net.Network)(0),                // 18: xray.common.net.Network
+	(*serial.TypedMessage)(nil),     // 19: xray.common.serial.TypedMessage
 }
 var file_app_router_config_proto_depIdxs = []int32{
 	0,  // 0: xray.app.router.Domain.type:type_name -> xray.app.router.Domain.Type
-	11, // 1: xray.app.router.Domain.attribute:type_name -> xray.app.router.Domain.Attribute
+	13, // 1: xray.app.router.Domain.attribute:type_name -> xray.app.router.Domain.Attribute
 	3,  // 2: xray.app.router.GeoIP.cidr:type_name -> xray.app.router.CIDR
 	4,  // 3: xray.app.router.GeoIPList.entry:type_name -> xray.app.router.GeoIP
 	2,  // 4: xray.app.router.GeoSite.domain:type_name -> xray.app.router.Domain
@@ -1103,22 +1297,24 @@ var file_app_router_config_proto_depIdxs = []int32{
 	2,  // 6: xray.app.router.RoutingRule.domain:type_name -> xray.app.router.Domain
 	3,  // 7: xray.app.router.RoutingRule.cidr:type_name -> xray.app.router.CIDR
 	4,  // 8: xray.app.router.RoutingRule.geoip:type_name -> xray.app.router.GeoIP
-	13, // 9: xray.app.router.RoutingRule.port_range:type_name -> xray.common.net.PortRange
-	14, // 10: xray.app.router.RoutingRule.port_list:type_name -> xray.common.net.PortList
-	15, // 11: xray.app.router.RoutingRule.network_list:type_name -> xray.common.net.NetworkList
-	16, // 12: xray.app.router.RoutingRule.networks:type_name -> xray.common.net.Network
+	15, // 9: xray.app.router.RoutingRule.port_range:type_name -> xray.common.net.PortRange
+	16, // 10: xray.app.router.RoutingRule.port_list:type_name -> xray.common.net.PortList
+	17, // 11: xray.app.router.RoutingRule.network_list:type_name -> xray.common.net.NetworkList
+	18, // 12: xray.app.router.RoutingRule.networks:type_name -> xray.common.net.Network
 	3,  // 13: xray.app.router.RoutingRule.source_cidr:type_name -> xray.app.router.CIDR
 	4,  // 14: xray.app.router.RoutingRule.source_geoip:type_name -> xray.app.router.GeoIP
-	14, // 15: xray.app.router.RoutingRule.source_port_list:type_name -> xray.common.net.PortList
-	12, // 16: xray.app.router.RoutingRule.attributes:type_name -> xray.app.router.RoutingRule.AttributesEntry
-	1,  // 17: xray.app.router.Config.domain_strategy:type_name -> xray.app.router.Config.DomainStrategy
-	8,  // 18: xray.app.router.Config.rule:type_name -> xray.app.router.RoutingRule
-	9,  // 19: xray.app.router.Config.balancing_rule:type_name -> xray.app.router.BalancingRule
-	20, // [20:20] is the sub-list for method output_type
-	20, // [20:20] is the sub-list for method input_type
-	20, // [20:20] is the sub-list for extension type_name
-	20, // [20:20] is the sub-list for extension extendee
-	0,  // [0:20] is the sub-list for field type_name
+	16, // 15: xray.app.router.RoutingRule.source_port_list:type_name -> xray.common.net.PortList
+	14, // 16: xray.app.router.RoutingRule.attributes:type_name -> xray.app.router.RoutingRule.AttributesEntry
+	19, // 17: xray.app.router.BalancingRule.strategy_settings:type_name -> xray.common.serial.TypedMessage
+	10, // 18: xray.app.router.StrategyLeastLoadConfig.costs:type_name -> xray.app.router.StrategyWeight
+	1,  // 19: xray.app.router.Config.domain_strategy:type_name -> xray.app.router.Config.DomainStrategy
+	8,  // 20: xray.app.router.Config.rule:type_name -> xray.app.router.RoutingRule
+	9,  // 21: xray.app.router.Config.balancing_rule:type_name -> xray.app.router.BalancingRule
+	22, // [22:22] is the sub-list for method output_type
+	22, // [22:22] is the sub-list for method input_type
+	22, // [22:22] is the sub-list for extension type_name
+	22, // [22:22] is the sub-list for extension extendee
+	0,  // [0:22] is the sub-list for field type_name
 }
 
 func init() { file_app_router_config_proto_init() }
@@ -1224,7 +1420,7 @@ func file_app_router_config_proto_init() {
 			}
 		}
 		file_app_router_config_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Config); i {
+			switch v := v.(*StrategyWeight); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -1236,6 +1432,30 @@ func file_app_router_config_proto_init() {
 			}
 		}
 		file_app_router_config_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*StrategyLeastLoadConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_config_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Config); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_app_router_config_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*Domain_Attribute); i {
 			case 0:
 				return &v.state
@@ -1252,7 +1472,7 @@ func file_app_router_config_proto_init() {
 		(*RoutingRule_Tag)(nil),
 		(*RoutingRule_BalancingTag)(nil),
 	}
-	file_app_router_config_proto_msgTypes[9].OneofWrappers = []interface{}{
+	file_app_router_config_proto_msgTypes[11].OneofWrappers = []interface{}{
 		(*Domain_Attribute_BoolValue)(nil),
 		(*Domain_Attribute_IntValue)(nil),
 	}
@@ -1262,7 +1482,7 @@ func file_app_router_config_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_app_router_config_proto_rawDesc,
 			NumEnums:      2,
-			NumMessages:   11,
+			NumMessages:   13,
 			NumExtensions: 0,
 			NumServices:   0,
 		},

+ 22 - 0
app/router/config.proto

@@ -6,6 +6,7 @@ option go_package = "github.com/xtls/xray-core/app/router";
 option java_package = "com.xray.app.router";
 option java_multiple_files = true;
 
+import "common/serial/typed_message.proto";
 import "common/net/port.proto";
 import "common/net/network.proto";
 
@@ -128,6 +129,27 @@ message BalancingRule {
   string tag = 1;
   repeated string outbound_selector = 2;
   string strategy = 3;
+  xray.common.serial.TypedMessage strategy_settings = 4;
+  string fallback_tag = 5;
+}
+
+message StrategyWeight {
+  bool regexp = 1;
+  string match = 2;
+  float value =3;
+}
+
+message StrategyLeastLoadConfig {
+  // weight settings
+  repeated StrategyWeight costs = 2;
+  // RTT baselines for selecting, int64 values of time.Duration
+  repeated int64 baselines = 3;
+  // expected nodes count to select
+  int32 expected = 4;
+  // max acceptable rtt, filter away high delay nodes. defalut 0
+  int64 maxRTT = 5;
+  // acceptable failure rate
+  float tolerance = 6;
 }
 
 message Config {

+ 6 - 6
app/router/router.go

@@ -29,13 +29,13 @@ type Route struct {
 }
 
 // Init initializes the Router.
-func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm outbound.Manager) error {
+func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm outbound.Manager, dispatcher routing.Dispatcher) error {
 	r.domainStrategy = config.DomainStrategy
 	r.dns = d
 
 	r.balancers = make(map[string]*Balancer, len(config.BalancingRule))
 	for _, rule := range config.BalancingRule {
-		balancer, err := rule.Build(ohm)
+		balancer, err := rule.Build(ohm, dispatcher)
 		if err != nil {
 			return err
 		}
@@ -113,12 +113,12 @@ func (r *Router) pickRouteInternal(ctx routing.Context) (*Rule, routing.Context,
 }
 
 // Start implements common.Runnable.
-func (*Router) Start() error {
+func (r *Router) Start() error {
 	return nil
 }
 
 // Close implements common.Closable.
-func (*Router) Close() error {
+func (r *Router) Close() error {
 	return nil
 }
 
@@ -140,8 +140,8 @@ func (r *Route) GetOutboundTag() string {
 func init() {
 	common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) {
 		r := new(Router)
-		if err := core.RequireFeatures(ctx, func(d dns.Client, ohm outbound.Manager) error {
-			return r.Init(ctx, config.(*Config), d, ohm)
+		if err := core.RequireFeatures(ctx, func(d dns.Client, ohm outbound.Manager, dispatcher routing.Dispatcher) error {
+			return r.Init(ctx, config.(*Config), d, ohm, dispatcher)
 		}); err != nil {
 			return nil, err
 		}

+ 54 - 5
app/router/router_test.go

@@ -43,7 +43,7 @@ func TestSimpleRouter(t *testing.T) {
 	common.Must(r.Init(context.TODO(), config, mockDNS, &mockOutboundManager{
 		Manager:         mockOhm,
 		HandlerSelector: mockHs,
-	}))
+	}, nil))
 
 	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.DomainAddress("example.com"), 80)})
 	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))
@@ -84,7 +84,7 @@ func TestSimpleBalancer(t *testing.T) {
 	common.Must(r.Init(context.TODO(), config, mockDNS, &mockOutboundManager{
 		Manager:         mockOhm,
 		HandlerSelector: mockHs,
-	}))
+	}, nil))
 
 	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.DomainAddress("example.com"), 80)})
 	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))
@@ -94,6 +94,55 @@ func TestSimpleBalancer(t *testing.T) {
 	}
 }
 
+/*
+
+Do not work right now: need a full client setup
+
+func TestLeastLoadBalancer(t *testing.T) {
+	config := &Config{
+		Rule: []*RoutingRule{
+			{
+				TargetTag: &RoutingRule_BalancingTag{
+					BalancingTag: "balance",
+				},
+				Networks: []net.Network{net.Network_TCP},
+			},
+		},
+		BalancingRule: []*BalancingRule{
+			{
+				Tag:              "balance",
+				OutboundSelector: []string{"test-"},
+				Strategy:         "leastLoad",
+				StrategySettings: serial.ToTypedMessage(&StrategyLeastLoadConfig{
+					Baselines:   nil,
+					Expected:    1,
+				}),
+			},
+		},
+	}
+
+	mockCtl := gomock.NewController(t)
+	defer mockCtl.Finish()
+
+	mockDNS := mocks.NewDNSClient(mockCtl)
+	mockOhm := mocks.NewOutboundManager(mockCtl)
+	mockHs := mocks.NewOutboundHandlerSelector(mockCtl)
+
+	mockHs.EXPECT().Select(gomock.Eq([]string{"test-"})).Return([]string{"test1"})
+
+	r := new(Router)
+	common.Must(r.Init(context.TODO(), config, mockDNS, &mockOutboundManager{
+		Manager:         mockOhm,
+		HandlerSelector: mockHs,
+	}, nil))
+	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.DomainAddress("v2ray.com"), 80)})
+	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))
+	common.Must(err)
+	if tag := route.GetOutboundTag(); tag != "test1" {
+		t.Error("expect tag 'test1', bug actually ", tag)
+	}
+}*/
+
 func TestIPOnDemand(t *testing.T) {
 	config := &Config{
 		DomainStrategy: Config_IpOnDemand,
@@ -123,7 +172,7 @@ func TestIPOnDemand(t *testing.T) {
 	}).Return([]net.IP{{192, 168, 0, 1}}, nil).AnyTimes()
 
 	r := new(Router)
-	common.Must(r.Init(context.TODO(), config, mockDNS, nil))
+	common.Must(r.Init(context.TODO(), config, mockDNS, nil, nil))
 
 	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.DomainAddress("example.com"), 80)})
 	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))
@@ -162,7 +211,7 @@ func TestIPIfNonMatchDomain(t *testing.T) {
 	}).Return([]net.IP{{192, 168, 0, 1}}, nil).AnyTimes()
 
 	r := new(Router)
-	common.Must(r.Init(context.TODO(), config, mockDNS, nil))
+	common.Must(r.Init(context.TODO(), config, mockDNS, nil, nil))
 
 	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.DomainAddress("example.com"), 80)})
 	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))
@@ -196,7 +245,7 @@ func TestIPIfNonMatchIP(t *testing.T) {
 	mockDNS := mocks.NewDNSClient(mockCtl)
 
 	r := new(Router)
-	common.Must(r.Init(context.TODO(), config, mockDNS, nil))
+	common.Must(r.Init(context.TODO(), config, mockDNS, nil, nil))
 
 	ctx := session.ContextWithOutbound(context.Background(), &session.Outbound{Target: net.TCPDestination(net.LocalHostIP, 80)})
 	route, err := r.PickRoute(routing_session.AsRoutingContext(ctx))

+ 200 - 0
app/router/strategy_leastload.go

@@ -0,0 +1,200 @@
+package router
+
+import (
+	"context"
+	"math"
+	"sort"
+	"time"
+
+	"github.com/xtls/xray-core/app/observatory"
+	"github.com/xtls/xray-core/common"
+	"github.com/xtls/xray-core/common/dice"
+	"github.com/xtls/xray-core/core"
+	"github.com/xtls/xray-core/features/extension"
+)
+
+// LeastLoadStrategy represents a least load balancing strategy
+type LeastLoadStrategy struct {
+	settings *StrategyLeastLoadConfig
+	costs    *WeightManager
+
+	observer extension.Observatory
+
+	ctx context.Context
+}
+
+func (l *LeastLoadStrategy) GetPrincipleTarget(strings []string) []string {
+	var ret []string
+	nodes := l.pickOutbounds(strings)
+	for _, v := range nodes {
+		ret = append(ret, v.Tag)
+	}
+	return ret
+}
+
+// NewLeastLoadStrategy creates a new LeastLoadStrategy with settings
+func NewLeastLoadStrategy(settings *StrategyLeastLoadConfig) *LeastLoadStrategy {
+	return &LeastLoadStrategy{
+		settings: settings,
+		costs: NewWeightManager(
+			settings.Costs, 1,
+			func(value, cost float64) float64 {
+				return value * math.Pow(cost, 0.5)
+			},
+		),
+	}
+}
+
+// node is a minimal copy of HealthCheckResult
+// we don't use HealthCheckResult directly because
+// it may change by health checker during routing
+type node struct {
+	Tag              string
+	CountAll         int
+	CountFail        int
+	RTTAverage       time.Duration
+	RTTDeviation     time.Duration
+	RTTDeviationCost time.Duration
+}
+
+func (l *LeastLoadStrategy) InjectContext(ctx context.Context) {
+	l.ctx = ctx
+}
+
+func (s *LeastLoadStrategy) PickOutbound(candidates []string) string {
+	selects := s.pickOutbounds(candidates)
+	count := len(selects)
+	if count == 0 {
+		// goes to fallbackTag
+		return ""
+	}
+	return selects[dice.Roll(count)].Tag
+}
+
+func (s *LeastLoadStrategy) pickOutbounds(candidates []string) []*node {
+	qualified := s.getNodes(candidates, time.Duration(s.settings.MaxRTT))
+	selects := s.selectLeastLoad(qualified)
+	return selects
+}
+
+// selectLeastLoad selects nodes according to Baselines and Expected Count.
+//
+// The strategy always improves network response speed, not matter which mode below is configured.
+// But they can still have different priorities.
+//
+// 1. Bandwidth priority: no Baseline + Expected Count > 0.: selects `Expected Count` of nodes.
+// (one if Expected Count <= 0)
+//
+// 2. Bandwidth priority advanced: Baselines + Expected Count > 0.
+// Select `Expected Count` amount of nodes, and also those near them according to baselines.
+// In other words, it selects according to different Baselines, until one of them matches
+// the Expected Count, if no Baseline matches, Expected Count applied.
+//
+// 3. Speed priority: Baselines + `Expected Count <= 0`.
+// go through all baselines until find selects, if not, select none. Used in combination
+// with 'balancer.fallbackTag', it means: selects qualified nodes or use the fallback.
+func (s *LeastLoadStrategy) selectLeastLoad(nodes []*node) []*node {
+	if len(nodes) == 0 {
+		newError("least load: no qualified outbound").AtInfo().WriteToLog()
+		return nil
+	}
+	expected := int(s.settings.Expected)
+	availableCount := len(nodes)
+	if expected > availableCount {
+		return nodes
+	}
+
+	if expected <= 0 {
+		expected = 1
+	}
+	if len(s.settings.Baselines) == 0 {
+		return nodes[:expected]
+	}
+
+	count := 0
+	// go through all base line until find expected selects
+	for _, b := range s.settings.Baselines {
+		baseline := time.Duration(b)
+		for i := count; i < availableCount; i++ {
+			if nodes[i].RTTDeviationCost >= baseline {
+				break
+			}
+			count = i + 1
+		}
+		// don't continue if find expected selects
+		if count >= expected {
+			newError("applied baseline: ", baseline).AtDebug().WriteToLog()
+			break
+		}
+	}
+	if s.settings.Expected > 0 && count < expected {
+		count = expected
+	}
+	return nodes[:count]
+}
+
+func (s *LeastLoadStrategy) getNodes(candidates []string, maxRTT time.Duration) []*node {
+	if s.observer == nil {
+		common.Must(core.RequireFeatures(s.ctx, func(observatory extension.Observatory) error {
+			s.observer = observatory
+			return nil
+		}))
+	}
+	observeResult, err := s.observer.GetObservation(s.ctx)
+	if err != nil {
+		newError("cannot get observation").Base(err).WriteToLog()
+		return make([]*node, 0)
+	}
+
+	results := observeResult.(*observatory.ObservationResult)
+
+	outboundlist := outboundList(candidates)
+
+	var ret []*node
+
+	for _, v := range results.Status {
+		if v.Alive && (v.Delay < maxRTT.Milliseconds() || maxRTT == 0) && outboundlist.contains(v.OutboundTag) {
+			record := &node{
+				Tag:              v.OutboundTag,
+				CountAll:         1,
+				CountFail:        1,
+				RTTAverage:       time.Duration(v.Delay) * time.Millisecond,
+				RTTDeviation:     time.Duration(v.Delay) * time.Millisecond,
+				RTTDeviationCost: time.Duration(s.costs.Apply(v.OutboundTag, float64(time.Duration(v.Delay)*time.Millisecond))),
+			}
+
+			if v.HealthPing != nil {
+				record.RTTAverage = time.Duration(v.HealthPing.Average)
+				record.RTTDeviation = time.Duration(v.HealthPing.Deviation)
+				record.RTTDeviationCost = time.Duration(s.costs.Apply(v.OutboundTag, float64(v.HealthPing.Deviation)))
+				record.CountAll = int(v.HealthPing.All)
+				record.CountFail = int(v.HealthPing.Fail)
+
+			}
+			ret = append(ret, record)
+		}
+	}
+
+	leastloadSort(ret)
+	return ret
+}
+
+func leastloadSort(nodes []*node) {
+	sort.Slice(nodes, func(i, j int) bool {
+		left := nodes[i]
+		right := nodes[j]
+		if left.RTTDeviationCost != right.RTTDeviationCost {
+			return left.RTTDeviationCost < right.RTTDeviationCost
+		}
+		if left.RTTAverage != right.RTTAverage {
+			return left.RTTAverage < right.RTTAverage
+		}
+		if left.CountFail != right.CountFail {
+			return left.CountFail < right.CountFail
+		}
+		if left.CountAll != right.CountAll {
+			return left.CountAll > right.CountAll
+		}
+		return left.Tag < right.Tag
+	})
+}

+ 179 - 0
app/router/strategy_leastload_test.go

@@ -0,0 +1,179 @@
+package router
+
+import (
+	"testing"
+)
+
+/*
+Split into multiple package, need to be tested separately
+
+func TestSelectLeastLoad(t *testing.T) {
+	settings := &StrategyLeastLoadConfig{
+		HealthCheck: &HealthPingConfig{
+			SamplingCount: 10,
+		},
+		Expected: 1,
+		MaxRTT:   int64(time.Millisecond * time.Duration(800)),
+	}
+	strategy := NewLeastLoadStrategy(settings)
+	// std 40
+	strategy.PutResult("a", time.Millisecond*time.Duration(60))
+	strategy.PutResult("a", time.Millisecond*time.Duration(140))
+	strategy.PutResult("a", time.Millisecond*time.Duration(60))
+	strategy.PutResult("a", time.Millisecond*time.Duration(140))
+	// std 60
+	strategy.PutResult("b", time.Millisecond*time.Duration(40))
+	strategy.PutResult("b", time.Millisecond*time.Duration(160))
+	strategy.PutResult("b", time.Millisecond*time.Duration(40))
+	strategy.PutResult("b", time.Millisecond*time.Duration(160))
+	// std 0, but >MaxRTT
+	strategy.PutResult("c", time.Millisecond*time.Duration(1000))
+	strategy.PutResult("c", time.Millisecond*time.Duration(1000))
+	strategy.PutResult("c", time.Millisecond*time.Duration(1000))
+	strategy.PutResult("c", time.Millisecond*time.Duration(1000))
+	expected := "a"
+	actual := strategy.SelectAndPick([]string{"a", "b", "c", "untested"})
+	if actual != expected {
+		t.Errorf("expected: %v, actual: %v", expected, actual)
+	}
+}
+
+func TestSelectLeastLoadWithCost(t *testing.T) {
+	settings := &StrategyLeastLoadConfig{
+		HealthCheck: &HealthPingConfig{
+			SamplingCount: 10,
+		},
+		Costs: []*StrategyWeight{
+			{Match: "a", Value: 9},
+		},
+		Expected: 1,
+	}
+	strategy := NewLeastLoadStrategy(settings, nil)
+	// std 40, std+c 120
+	strategy.PutResult("a", time.Millisecond*time.Duration(60))
+	strategy.PutResult("a", time.Millisecond*time.Duration(140))
+	strategy.PutResult("a", time.Millisecond*time.Duration(60))
+	strategy.PutResult("a", time.Millisecond*time.Duration(140))
+	// std 60
+	strategy.PutResult("b", time.Millisecond*time.Duration(40))
+	strategy.PutResult("b", time.Millisecond*time.Duration(160))
+	strategy.PutResult("b", time.Millisecond*time.Duration(40))
+	strategy.PutResult("b", time.Millisecond*time.Duration(160))
+	expected := "b"
+	actual := strategy.SelectAndPick([]string{"a", "b", "untested"})
+	if actual != expected {
+		t.Errorf("expected: %v, actual: %v", expected, actual)
+	}
+}
+*/
+func TestSelectLeastExpected(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: nil,
+			Expected:  3,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 100},
+		{Tag: "b", RTTDeviationCost: 200},
+		{Tag: "c", RTTDeviationCost: 300},
+		{Tag: "d", RTTDeviationCost: 350},
+	}
+	expected := 3
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}
+func TestSelectLeastExpected2(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: nil,
+			Expected:  3,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 100},
+		{Tag: "b", RTTDeviationCost: 200},
+	}
+	expected := 2
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}
+func TestSelectLeastExpectedAndBaselines(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: []int64{200, 300, 400},
+			Expected:  3,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 100},
+		{Tag: "b", RTTDeviationCost: 200},
+		{Tag: "c", RTTDeviationCost: 250},
+		{Tag: "d", RTTDeviationCost: 300},
+		{Tag: "e", RTTDeviationCost: 310},
+	}
+	expected := 3
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}
+func TestSelectLeastExpectedAndBaselines2(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: []int64{200, 300, 400},
+			Expected:  3,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 500},
+		{Tag: "b", RTTDeviationCost: 600},
+		{Tag: "c", RTTDeviationCost: 700},
+		{Tag: "d", RTTDeviationCost: 800},
+		{Tag: "e", RTTDeviationCost: 900},
+	}
+	expected := 3
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}
+func TestSelectLeastLoadBaselines(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: []int64{200, 400, 600},
+			Expected:  0,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 100},
+		{Tag: "b", RTTDeviationCost: 200},
+		{Tag: "c", RTTDeviationCost: 300},
+	}
+	expected := 1
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}
+func TestSelectLeastLoadBaselinesNoQualified(t *testing.T) {
+	strategy := &LeastLoadStrategy{
+		settings: &StrategyLeastLoadConfig{
+			Baselines: []int64{200, 400, 600},
+			Expected:  0,
+		},
+	}
+	nodes := []*node{
+		{Tag: "a", RTTDeviationCost: 800},
+		{Tag: "b", RTTDeviationCost: 1000},
+	}
+	expected := 0
+	ns := strategy.selectLeastLoad(nodes)
+	if len(ns) != expected {
+		t.Errorf("expected: %v, actual: %v", expected, len(ns))
+	}
+}

+ 4 - 0
app/router/strategy_leastping.go

@@ -14,6 +14,10 @@ type LeastPingStrategy struct {
 	observatory extension.Observatory
 }
 
+func (l *LeastPingStrategy) GetPrincipleTarget(strings []string) []string {
+	return []string{l.PickOutbound(strings)}
+}
+
 func (l *LeastPingStrategy) InjectContext(ctx context.Context) {
 	l.ctx = ctx
 }

+ 21 - 0
app/router/strategy_random.go

@@ -0,0 +1,21 @@
+package router
+
+import (
+	"github.com/xtls/xray-core/common/dice"
+)
+
+// RandomStrategy represents a random balancing strategy
+type RandomStrategy struct{}
+
+func (s *RandomStrategy) GetPrincipleTarget(strings []string) []string {
+	return strings
+}
+
+func (s *RandomStrategy) PickOutbound(candidates []string) string {
+	count := len(candidates)
+	if count == 0 {
+		// goes to fallbackTag
+		return ""
+	}
+	return candidates[dice.Roll(count)]
+}

+ 89 - 0
app/router/weight.go

@@ -0,0 +1,89 @@
+package router
+
+import (
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+)
+
+type weightScaler func(value, weight float64) float64
+
+var numberFinder = regexp.MustCompile(`\d+(\.\d+)?`)
+
+// NewWeightManager creates a new WeightManager with settings
+func NewWeightManager(s []*StrategyWeight, defaultWeight float64, scaler weightScaler) *WeightManager {
+	return &WeightManager{
+		settings:      s,
+		cache:         make(map[string]float64),
+		scaler:        scaler,
+		defaultWeight: defaultWeight,
+	}
+}
+
+// WeightManager manages weights for specific settings
+type WeightManager struct {
+	settings      []*StrategyWeight
+	cache         map[string]float64
+	scaler        weightScaler
+	defaultWeight float64
+	mu            sync.Mutex
+}
+
+// Get get the weight of specified tag
+func (s *WeightManager) Get(tag string) float64 {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	weight, ok := s.cache[tag]
+	if ok {
+		return weight
+	}
+	weight = s.findValue(tag)
+	s.cache[tag] = weight
+	return weight
+}
+
+// Apply applies weight to the value
+func (s *WeightManager) Apply(tag string, value float64) float64 {
+	return s.scaler(value, s.Get(tag))
+}
+
+func (s *WeightManager) findValue(tag string) float64 {
+	for _, w := range s.settings {
+		matched := s.getMatch(tag, w.Match, w.Regexp)
+		if matched == "" {
+			continue
+		}
+		if w.Value > 0 {
+			return float64(w.Value)
+		}
+		// auto weight from matched
+		numStr := numberFinder.FindString(matched)
+		if numStr == "" {
+			return s.defaultWeight
+		}
+		weight, err := strconv.ParseFloat(numStr, 64)
+		if err != nil {
+			newError("unexpected error from ParseFloat: ", err).AtError().WriteToLog()
+			return s.defaultWeight
+		}
+		return weight
+	}
+	return s.defaultWeight
+}
+
+func (s *WeightManager) getMatch(tag, find string, isRegexp bool) string {
+	if !isRegexp {
+		idx := strings.Index(tag, find)
+		if idx < 0 {
+			return ""
+		}
+		return find
+	}
+	r, err := regexp.Compile(find)
+	if err != nil {
+		newError("invalid regexp: ", find, "err: ", err).AtError().WriteToLog()
+		return ""
+	}
+	return r.FindString(tag)
+}

+ 60 - 0
app/router/weight_test.go

@@ -0,0 +1,60 @@
+package router_test
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/xtls/xray-core/app/router"
+)
+
+func TestWeight(t *testing.T) {
+	manager := router.NewWeightManager(
+		[]*router.StrategyWeight{
+			{
+				Match: "x5",
+				Value: 100,
+			},
+			{
+				Match: "x8",
+			},
+			{
+				Regexp: true,
+				Match:  `\bx0+(\.\d+)?\b`,
+				Value:  1,
+			},
+			{
+				Regexp: true,
+				Match:  `\bx\d+(\.\d+)?\b`,
+			},
+		},
+		1, func(v, w float64) float64 {
+			return v * w
+		},
+	)
+	tags := []string{
+		"node name, x5, and more",
+		"node name, x8",
+		"node name, x15",
+		"node name, x0100, and more",
+		"node name, x10.1",
+		"node name, x00.1, and more",
+	}
+	// test weight
+	expected := []float64{100, 8, 15, 100, 10.1, 1}
+	actual := make([]float64, 0)
+	for _, tag := range tags {
+		actual = append(actual, manager.Get(tag))
+	}
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("expected: %v, actual: %v", expected, actual)
+	}
+	// test scale
+	expected2 := []float64{1000, 80, 150, 1000, 101, 10}
+	actual2 := make([]float64, 0)
+	for _, tag := range tags {
+		actual2 = append(actual2, manager.Apply(tag, 10))
+	}
+	if !reflect.DeepEqual(expected2, actual2) {
+		t.Errorf("expected2: %v, actual2: %v", expected2, actual2)
+	}
+}

+ 1 - 1
app/stats/command/command.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/stats/command/command.proto
 

+ 1 - 1
app/stats/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: app/stats/config.proto
 

+ 1 - 1
common/log/log.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/log/log.proto
 

+ 1 - 1
common/net/address.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/net/address.proto
 

+ 1 - 1
common/net/destination.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/net/destination.proto
 

+ 1 - 1
common/net/network.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/net/network.proto
 

+ 1 - 1
common/net/port.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/net/port.proto
 

+ 1 - 1
common/protocol/headers.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/protocol/headers.proto
 

+ 1 - 1
common/protocol/server_spec.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/protocol/server_spec.proto
 

+ 1 - 1
common/protocol/user.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/protocol/user.proto
 

+ 1 - 1
common/serial/typed_message.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: common/serial/typed_message.proto
 

+ 1 - 0
common/session/context.go

@@ -24,6 +24,7 @@ const (
 	dispatcherKey
 	timeoutOnlyKey
 	allowedNetworkKey
+	handlerSessionKey
 )
 
 // ContextWithID returns a new context with the given ID.

+ 1 - 1
core/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: core/config.proto
 

+ 10 - 0
features/routing/balancer.go

@@ -0,0 +1,10 @@
+package routing
+
+type BalancerOverrider interface {
+	SetOverrideTarget(tag, target string) error
+	GetOverrideTarget(tag string) (string, error)
+}
+
+type BalancerPrincipleTarget interface {
+	GetPrincipleTarget(tag string) ([]string, error)
+}

+ 3 - 0
infra/conf/api.go

@@ -7,6 +7,7 @@ import (
 	loggerservice "github.com/xtls/xray-core/app/log/command"
 	observatoryservice "github.com/xtls/xray-core/app/observatory/command"
 	handlerservice "github.com/xtls/xray-core/app/proxyman/command"
+	routerservice "github.com/xtls/xray-core/app/router/command"
 	statsservice "github.com/xtls/xray-core/app/stats/command"
 	"github.com/xtls/xray-core/common/serial"
 )
@@ -34,6 +35,8 @@ func (c *APIConfig) Build() (*commander.Config, error) {
 			services = append(services, serial.ToTypedMessage(&statsservice.Config{}))
 		case "observatoryservice":
 			services = append(services, serial.ToTypedMessage(&observatoryservice.Config{}))
+		case "routingservice":
+			services = append(services, serial.ToTypedMessage(&routerservice.Config{}))
 		}
 	}
 

+ 17 - 1
infra/conf/observatory.go

@@ -1,9 +1,11 @@
 package conf
 
 import (
+	"google.golang.org/protobuf/proto"
+	
 	"github.com/xtls/xray-core/app/observatory"
+	"github.com/xtls/xray-core/app/observatory/burst"
 	"github.com/xtls/xray-core/infra/conf/cfgcommon/duration"
-	"google.golang.org/protobuf/proto"
 )
 
 type ObservatoryConfig struct {
@@ -16,3 +18,17 @@ type ObservatoryConfig struct {
 func (o *ObservatoryConfig) Build() (proto.Message, error) {
 	return &observatory.Config{SubjectSelector: o.SubjectSelector, ProbeUrl: o.ProbeURL, ProbeInterval: int64(o.ProbeInterval), EnableConcurrency: o.EnableConcurrency}, nil
 }
+
+type BurstObservatoryConfig struct {
+	SubjectSelector []string `json:"subjectSelector"`
+	// health check settings
+	HealthCheck *healthCheckSettings `json:"pingConfig,omitempty"`
+}
+
+func (b BurstObservatoryConfig) Build() (proto.Message, error) {
+	if result, err := b.HealthCheck.Build(); err == nil {
+		return &burst.Config{SubjectSelector: b.SubjectSelector, PingConfig: result.(*burst.HealthPingConfig)}, nil
+	} else {
+		return nil, err
+	}
+}

+ 31 - 13
infra/conf/router.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/xtls/xray-core/app/router"
 	"github.com/xtls/xray-core/common/net"
+	"github.com/xtls/xray-core/common/serial"
 	"github.com/xtls/xray-core/common/platform/filesystem"
 	"google.golang.org/protobuf/proto"
 )
@@ -24,11 +25,13 @@ type StrategyConfig struct {
 }
 
 type BalancingRule struct {
-	Tag       string         `json:"tag"`
-	Selectors StringList     `json:"selector"`
-	Strategy  StrategyConfig `json:"strategy"`
+	Tag         string         `json:"tag"`
+	Selectors   StringList     `json:"selector"`
+	Strategy    StrategyConfig `json:"strategy"`
+	FallbackTag string         `json:"fallbackTag"`
 }
 
+// Build builds the balancing rule
 func (r *BalancingRule) Build() (*router.BalancingRule, error) {
 	if r.Tag == "" {
 		return nil, newError("empty balancer tag")
@@ -37,22 +40,37 @@ func (r *BalancingRule) Build() (*router.BalancingRule, error) {
 		return nil, newError("empty selector list")
 	}
 
-	var strategy string
-	switch strings.ToLower(r.Strategy.Type) {
-	case strategyRandom, "":
-		strategy = strategyRandom
-	case strategyLeastPing:
-		strategy = "leastPing"
-	case strategyRoundRobin:
-		strategy = "roundRobin"
+	r.Strategy.Type = strings.ToLower(r.Strategy.Type)
+	switch r.Strategy.Type {
+	case "":
+		r.Strategy.Type = strategyRandom
+	case strategyRandom, strategyLeastLoad, strategyLeastPing, strategyRoundRobin:
 	default:
 		return nil, newError("unknown balancing strategy: " + r.Strategy.Type)
 	}
 
+	settings := []byte("{}")
+	if r.Strategy.Settings != nil {
+		settings = ([]byte)(*r.Strategy.Settings)
+	}
+	rawConfig, err := strategyConfigLoader.LoadWithID(settings, r.Strategy.Type)
+	if err != nil {
+		return nil, newError("failed to parse to strategy config.").Base(err)
+	}
+	var ts proto.Message
+	if builder, ok := rawConfig.(Buildable); ok {
+		ts, err = builder.Build()
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	return &router.BalancingRule{
+		Strategy:         r.Strategy.Type,
+		StrategySettings: serial.ToTypedMessage(ts),
+		FallbackTag:      r.FallbackTag,
+		OutboundSelector: r.Selectors,
 		Tag:              r.Tag,
-		OutboundSelector: []string(r.Selectors),
-		Strategy:         strategy,
 	}, nil
 }
 

+ 86 - 0
infra/conf/router_strategy.go

@@ -1,7 +1,93 @@
 package conf
 
+import (
+	"google.golang.org/protobuf/proto"
+	
+	"github.com/xtls/xray-core/app/router"
+	"github.com/xtls/xray-core/app/observatory/burst"
+	"github.com/xtls/xray-core/infra/conf/cfgcommon/duration"
+)
+
 const (
 	strategyRandom     string = "random"
 	strategyLeastPing  string = "leastping"
 	strategyRoundRobin string = "roundrobin"
+	strategyLeastLoad  string = "leastload"
 )
+
+var (
+	strategyConfigLoader = NewJSONConfigLoader(ConfigCreatorCache{
+		strategyRandom:     func() interface{} { return new(strategyEmptyConfig) },
+		strategyLeastPing:  func() interface{} { return new(strategyEmptyConfig) },
+		strategyRoundRobin: func() interface{} { return new(strategyEmptyConfig) },
+		strategyLeastLoad:  func() interface{} { return new(strategyLeastLoadConfig) },
+	}, "type", "settings")
+)
+
+type strategyEmptyConfig struct {
+}
+
+func (v *strategyEmptyConfig) Build() (proto.Message, error) {
+	return nil, nil
+}
+
+type strategyLeastLoadConfig struct {
+	// weight settings
+	Costs []*router.StrategyWeight `json:"costs,omitempty"`
+	// ping rtt baselines
+	Baselines []duration.Duration `json:"baselines,omitempty"`
+	// expected nodes count to select
+	Expected int32 `json:"expected,omitempty"`
+	// max acceptable rtt, filter away high delay nodes. defalut 0
+	MaxRTT duration.Duration `json:"maxRTT,omitempty"`
+	// acceptable failure rate
+	Tolerance float64 `json:"tolerance,omitempty"`
+}
+
+// healthCheckSettings holds settings for health Checker
+type healthCheckSettings struct {
+	Destination   string   `json:"destination"`
+	Connectivity  string   `json:"connectivity"`
+	Interval      duration.Duration `json:"interval"`
+	SamplingCount int      `json:"sampling"`
+	Timeout       duration.Duration `json:"timeout"`
+}
+
+func (h healthCheckSettings) Build() (proto.Message, error) {
+	return &burst.HealthPingConfig{
+		Destination:   h.Destination,
+		Connectivity:  h.Connectivity,
+		Interval:      int64(h.Interval),
+		Timeout:       int64(h.Timeout),
+		SamplingCount: int32(h.SamplingCount),
+	}, nil
+}
+
+// Build implements Buildable.
+func (v *strategyLeastLoadConfig) Build() (proto.Message, error) {
+	config := &router.StrategyLeastLoadConfig{}
+	config.Costs = v.Costs
+	config.Tolerance = float32(v.Tolerance)
+	if config.Tolerance < 0 {
+		config.Tolerance = 0
+	}
+	if config.Tolerance > 1 {
+		config.Tolerance = 1
+	}
+	config.Expected = v.Expected
+	if config.Expected < 0 {
+		config.Expected = 0
+	}
+	config.MaxRTT = int64(v.MaxRTT)
+	if config.MaxRTT < 0 {
+		config.MaxRTT = 0
+	}
+	config.Baselines = make([]int64, 0)
+	for _, b := range v.Baselines {
+		if b <= 0 {
+			continue
+		}
+		config.Baselines = append(config.Baselines, int64(b))
+	}
+	return config, nil
+}

+ 52 - 0
infra/conf/router_test.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+	"time"
 	_ "unsafe"
 
 	"github.com/xtls/xray-core/app/router"
@@ -12,6 +13,7 @@ import (
 	"github.com/xtls/xray-core/common/net"
 	"github.com/xtls/xray-core/common/platform"
 	"github.com/xtls/xray-core/common/platform/filesystem"
+	"github.com/xtls/xray-core/common/serial"
 	. "github.com/xtls/xray-core/infra/conf"
 	"google.golang.org/protobuf/proto"
 )
@@ -96,6 +98,34 @@ func TestRouterConfig(t *testing.T) {
 					{
 						"tag": "b1",
 						"selector": ["test"]
+					},
+					{
+						"tag": "b2",
+						"selector": ["test"],
+						"strategy": {
+							"type": "leastload",
+							"settings": {
+								"healthCheck": {
+									"interval": "5m0s",
+									"sampling": 2,
+									"timeout": "5s",
+									"destination": "dest",
+									"connectivity": "conn"
+								},
+								"costs": [
+									{
+										"regexp": true,
+										"match": "\\d+(\\.\\d+)",
+										"value": 5
+									}
+								],
+								"baselines": ["400ms", "600ms"],
+								"expected": 6,
+								"maxRTT": "1000ms",
+								"tolerance": 0.5
+							}
+						},
+						"fallbackTag": "fall"
 					}
 				]
 			}`,
@@ -108,6 +138,28 @@ func TestRouterConfig(t *testing.T) {
 						OutboundSelector: []string{"test"},
 						Strategy:         "random",
 					},
+					{
+						Tag:              "b2",
+						OutboundSelector: []string{"test"},
+						Strategy:         "leastload",
+						StrategySettings: serial.ToTypedMessage(&router.StrategyLeastLoadConfig{
+							Costs: []*router.StrategyWeight{
+								{
+									Regexp: true,
+									Match:  "\\d+(\\.\\d+)",
+									Value:  5,
+								},
+							},
+							Baselines: []int64{
+								int64(time.Duration(400) * time.Millisecond),
+								int64(time.Duration(600) * time.Millisecond),
+							},
+							Expected:  6,
+							MaxRTT:    int64(time.Duration(1000) * time.Millisecond),
+							Tolerance: 0.5,
+						}),
+						FallbackTag: "fall",
+					},
 				},
 				Rule: []*router.RoutingRule{
 					{

+ 9 - 0
infra/conf/xray.go

@@ -410,6 +410,7 @@ type Config struct {
 	Reverse         *ReverseConfig         `json:"reverse"`
 	FakeDNS         *FakeDNSConfig         `json:"fakeDns"`
 	Observatory     *ObservatoryConfig     `json:"observatory"`
+	BurstObservatory *BurstObservatoryConfig `json:"burstObservatory"`
 }
 
 func (c *Config) findInboundTag(tag string) int {
@@ -639,6 +640,14 @@ func (c *Config) Build() (*core.Config, error) {
 		config.App = append(config.App, serial.ToTypedMessage(r))
 	}
 
+	if c.BurstObservatory != nil {
+		r, err := c.BurstObservatory.Build()
+		if err != nil {
+			return nil, err
+		}
+		config.App = append(config.App, serial.ToTypedMessage(r))
+	}
+
 	var inbounds []InboundDetourConfig
 
 	if c.InboundConfig != nil {

+ 2 - 0
main/commands/all/api/api.go

@@ -15,6 +15,8 @@ var CmdAPI = &base.Command{
 		cmdGetStats,
 		cmdQueryStats,
 		cmdSysStats,
+		cmdBalancerInfo,
+		cmdBalancerOverride,
 		cmdAddInbounds,
 		cmdAddOutbounds,
 		cmdRemoveInbounds,

+ 108 - 0
main/commands/all/api/balancer_info.go

@@ -0,0 +1,108 @@
+package api
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	routerService "github.com/xtls/xray-core/app/router/command"
+	"github.com/xtls/xray-core/main/commands/base"
+)
+
+// TODO: support "-json" flag for json output
+var cmdBalancerInfo = &base.Command{
+	CustomFlags: true,
+	UsageLine:   "{{.Exec}} api bi [--server=127.0.0.1:8080] [balancer]...",
+	Short:       "balancer information",
+	Long: `
+Get information of specified balancers, including health, strategy 
+and selecting. If no balancer tag specified, get information of 
+all balancers.
+
+> Make sure you have "RoutingService" set in "config.api.services" 
+of server config.
+
+Arguments:
+
+	-json
+		Use json output.
+
+	-s, -server <server:port>
+		The API server address. Default 127.0.0.1:8080
+
+	-t, -timeout <seconds>
+		Timeout seconds to call API. Default 3
+
+Example:
+
+    {{.Exec}} {{.LongName}} --server=127.0.0.1:8080 balancer1 balancer2
+`,
+	Run: executeBalancerInfo,
+}
+
+func executeBalancerInfo(cmd *base.Command, args []string) {
+	setSharedFlags(cmd)
+	cmd.Flag.Parse(args)
+
+	conn, ctx, close := dialAPIServer()
+	defer close()
+
+	client := routerService.NewRoutingServiceClient(conn)
+	r := &routerService.GetBalancerInfoRequest{Tag: args[0]}
+	resp, err := client.GetBalancerInfo(ctx, r)
+	if err != nil {
+		base.Fatalf("failed to get health information: %s", err)
+	}
+
+	if apiJSON {
+		showJSONResponse(resp)
+		return
+	}
+
+	showBalancerInfo(resp.Balancer)
+
+}
+
+func showBalancerInfo(b *routerService.BalancerMsg) {
+	const tableIndent = 4
+	sb := new(strings.Builder)
+	// Override
+	if b.Override != nil {
+		sb.WriteString("  - Selecting Override:\n")
+		for i, s := range []string{b.Override.Target} {
+			writeRow(sb, tableIndent, i+1, []string{s}, nil)
+		}
+	}
+	// Selects
+	sb.WriteString("  - Selects:\n")
+
+	for i, o := range b.PrincipleTarget.Tag {
+		writeRow(sb, tableIndent, i+1, []string{o}, nil)
+	}
+	os.Stdout.WriteString(sb.String())
+}
+
+func getColumnFormats(titles []string) []string {
+	w := make([]string, len(titles))
+	for i, t := range titles {
+		w[i] = fmt.Sprintf("%%-%ds ", len(t))
+	}
+	return w
+}
+
+func writeRow(sb *strings.Builder, indent, index int, values, formats []string) {
+	if index == 0 {
+		// title line
+		sb.WriteString(strings.Repeat(" ", indent+4))
+	} else {
+		sb.WriteString(fmt.Sprintf("%s%-4d", strings.Repeat(" ", indent), index))
+	}
+	for i, v := range values {
+		format := "%-14s"
+		if i < len(formats) {
+			format = formats[i]
+		}
+		sb.WriteString(fmt.Sprintf(format, v))
+	}
+	sb.WriteByte('\n')
+}

+ 77 - 0
main/commands/all/api/balancer_override.go

@@ -0,0 +1,77 @@
+package api
+
+import (
+	routerService "github.com/xtls/xray-core/app/router/command"
+	"github.com/xtls/xray-core/main/commands/base"
+)
+
+var cmdBalancerOverride = &base.Command{
+	CustomFlags: true,
+	UsageLine:   "{{.Exec}} api bo [--server=127.0.0.1:8080] <-b balancer> outboundTag",
+	Short:       "balancer override",
+	Long: `
+Override a balancer's selection.
+
+> Make sure you have "RoutingService" set in "config.api.services" 
+of server config.
+
+Once a balancer's selecting is overridden:
+
+- The balancer's selection result will always be outboundTag
+
+Arguments:
+
+	-r, -remove
+		Remove the overridden
+
+	-r, -remove
+		Remove the override
+
+	-s, -server 
+		The API server address. Default 127.0.0.1:8080
+
+	-t, -timeout
+		Timeout seconds to call API. Default 3
+
+Example:
+
+    {{.Exec}} {{.LongName}} --server=127.0.0.1:8080 -b balancer tag
+    {{.Exec}} {{.LongName}} --server=127.0.0.1:8080 -b balancer -r
+`,
+	Run: executeBalancerOverride,
+}
+
+func executeBalancerOverride(cmd *base.Command, args []string) {
+	var (
+		balancer string
+		remove   bool
+	)
+	cmd.Flag.StringVar(&balancer, "b", "", "")
+	cmd.Flag.StringVar(&balancer, "balancer", "", "")
+	cmd.Flag.BoolVar(&remove, "r", false, "")
+	cmd.Flag.BoolVar(&remove, "remove", false, "")
+	setSharedFlags(cmd)
+	cmd.Flag.Parse(args)
+
+	if balancer == "" {
+		base.Fatalf("balancer tag not specified")
+	}
+
+	conn, ctx, close := dialAPIServer()
+	defer close()
+
+	client := routerService.NewRoutingServiceClient(conn)
+	target := ""
+	if !remove {
+		target = cmd.Flag.Args()[0]
+	}
+	r := &routerService.OverrideBalancerTargetRequest{
+		BalancerTag: balancer,
+		Target:      target,
+	}
+
+	_, err := client.OverrideBalancerTarget(ctx, r)
+	if err != nil {
+		base.Fatalf("failed to perform balancer health checks: %s", err)
+	}
+}

+ 5 - 8
main/commands/all/api/shared.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"google.golang.org/protobuf/encoding/protojson"
 	"io"
 	"net/http"
 	"net/url"
@@ -15,7 +16,6 @@ import (
 	"github.com/xtls/xray-core/common/buf"
 	"github.com/xtls/xray-core/main/commands/base"
 	"google.golang.org/grpc"
-	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/proto"
 )
 
@@ -24,6 +24,7 @@ type serviceHandler func(ctx context.Context, conn *grpc.ClientConn, cmd *base.C
 var (
 	apiServerAddrPtr string
 	apiTimeout       int
+	apiJSON          bool
 )
 
 func setSharedFlags(cmd *base.Command) {
@@ -31,6 +32,7 @@ func setSharedFlags(cmd *base.Command) {
 	cmd.Flag.StringVar(&apiServerAddrPtr, "server", "127.0.0.1:8080", "")
 	cmd.Flag.IntVar(&apiTimeout, "t", 3, "")
 	cmd.Flag.IntVar(&apiTimeout, "timeout", 3, "")
+	cmd.Flag.BoolVar(&apiJSON, "json", false, "")
 }
 
 func dialAPIServer() (conn *grpc.ClientConn, ctx context.Context, close func()) {
@@ -103,13 +105,8 @@ func fetchHTTPContent(target string) ([]byte, error) {
 	return content, nil
 }
 
-func protoToJSONString(m proto.Message, _, indent string) (string, error) {
-	ops := protojson.MarshalOptions{
-		Indent:          indent,
-		EmitUnpopulated: true,
-	}
-	b, err := ops.Marshal(m)
-	return string(b), err
+func protoToJSONString(m proto.Message, prefix, indent string) (string, error) {
+	return strings.TrimSpace(protojson.MarshalOptions{Indent: indent}.Format(m)), nil
 }
 
 func showJSONResponse(m proto.Message) {

+ 1 - 1
proxy/blackhole/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/blackhole/config.proto
 

+ 1 - 1
proxy/dns/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/dns/config.proto
 

+ 1 - 1
proxy/dokodemo/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/dokodemo/config.proto
 

+ 1 - 1
proxy/freedom/config.pb.go

@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.32.0
-// 	protoc        v4.25.2
+// 	protoc        v4.23.1
 // source: proxy/freedom/config.proto
 
 package freedom

+ 1 - 1
proxy/http/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/http/config.proto
 

+ 1 - 1
proxy/loopback/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/loopback/config.proto
 

+ 1 - 1
proxy/shadowsocks/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/shadowsocks/config.proto
 

+ 1 - 1
proxy/shadowsocks_2022/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/shadowsocks_2022/config.proto
 

+ 1 - 1
proxy/socks/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/socks/config.proto
 

+ 1 - 1
proxy/trojan/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/trojan/config.proto
 

+ 1 - 1
proxy/vless/account.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vless/account.proto
 

+ 1 - 1
proxy/vless/encoding/addons.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vless/encoding/addons.proto
 

+ 1 - 1
proxy/vless/inbound/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vless/inbound/config.proto
 

+ 1 - 1
proxy/vless/outbound/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vless/outbound/config.proto
 

+ 1 - 1
proxy/vmess/account.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vmess/account.proto
 

+ 1 - 1
proxy/vmess/inbound/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vmess/inbound/config.proto
 

+ 1 - 1
proxy/vmess/outbound/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/vmess/outbound/config.proto
 

+ 1 - 1
proxy/wireguard/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: proxy/wireguard/config.proto
 

+ 1 - 1
transport/global/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/global/config.proto
 

+ 1 - 1
transport/internet/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/config.proto
 

+ 1 - 1
transport/internet/domainsocket/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/domainsocket/config.proto
 

+ 1 - 1
transport/internet/grpc/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/grpc/config.proto
 

+ 1 - 1
transport/internet/grpc/encoding/stream.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/grpc/encoding/stream.proto
 

+ 1 - 1
transport/internet/headers/dns/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/dns/config.proto
 

+ 1 - 1
transport/internet/headers/http/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/http/config.proto
 

+ 1 - 1
transport/internet/headers/noop/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/noop/config.proto
 

+ 1 - 1
transport/internet/headers/srtp/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/srtp/config.proto
 

+ 1 - 1
transport/internet/headers/tls/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/tls/config.proto
 

+ 1 - 1
transport/internet/headers/utp/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/utp/config.proto
 

+ 1 - 1
transport/internet/headers/wechat/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/wechat/config.proto
 

+ 1 - 1
transport/internet/headers/wireguard/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/headers/wireguard/config.proto
 

+ 1 - 1
transport/internet/http/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/http/config.proto
 

+ 1 - 1
transport/internet/kcp/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/kcp/config.proto
 

+ 1 - 1
transport/internet/quic/config.pb.go

@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.31.0
+// 	protoc-gen-go v1.32.0
 // 	protoc        v4.23.1
 // source: transport/internet/quic/config.proto
 

Some files were not shown because too many files changed in this diff