From 3e8551814a39062effbb398b7ecf9de634b89ffe Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 12:32:32 +0330 Subject: [PATCH 01/20] feat(xray): add sanitizeAPIServices to merge user API services --- backend/xray/config.go | 57 +++++++++++++++++++++++++++++++++++ backend/xray/config_test.go | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 backend/xray/config_test.go diff --git a/backend/xray/config.go b/backend/xray/config.go index 9ffa05e..3150b9e 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -527,6 +527,63 @@ func normalizeGeoIPPrivateRules(rules []json.RawMessage) ([]json.RawMessage, err return normalized, nil } +// canonicalAPIServices maps a lowercased service name to its canonical form. +// ponytail: mirrors the switch in xray-core infra/conf/api.go APIConfig.Build(). +// Ceiling: if xray-core adds/removes a service this must be synced by hand +// (TestSanitizeAPIServices pins the set so a drifted upgrade fails loudly). +var canonicalAPIServices = map[string]string{ + "reflectionservice": "ReflectionService", + "handlerservice": "HandlerService", + "loggerservice": "LoggerService", + "statsservice": "StatsService", + "observatoryservice": "ObservatoryService", + "routingservice": "RoutingService", +} + +// requiredAPIServices are always present — the node's own gRPC clients depend on +// HandlerService (users/inbounds) and StatsService (traffic); LoggerService +// preserves current behavior. +var requiredAPIServices = []string{"HandlerService", "LoggerService", "StatsService"} + +// sanitizeAPIServices returns the required API services plus any valid +// user-provided extras (canonicalized, deduped case-insensitively, sorted). +// Unknown service names are dropped with a logged warning rather than failing +// the backend, matching the rest of ApplyAPI's defensive normalization. +func sanitizeAPIServices(userProvided []string) []string { + seen := make(map[string]struct{}, len(requiredAPIServices)+len(userProvided)) + result := make([]string, 0, len(requiredAPIServices)+len(userProvided)) + + for _, s := range requiredAPIServices { + key := strings.ToLower(s) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + result = append(result, s) + } + + extras := make([]string, 0, len(userProvided)) + for _, s := range userProvided { + key := strings.ToLower(strings.TrimSpace(s)) + if key == "" { + continue + } + canonical, ok := canonicalAPIServices[key] + if !ok { + log.Printf("xray config: dropping unknown API service %q", s) + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + extras = append(extras, canonical) + } + + sort.Strings(extras) + return append(result, extras...) +} + func apiRuleSources() []string { seen := map[string]struct{}{ "127.0.0.1": {}, diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go new file mode 100644 index 0000000..04bb89b --- /dev/null +++ b/backend/xray/config_test.go @@ -0,0 +1,59 @@ +package xray + +import ( + "slices" + "testing" +) + +func TestSanitizeAPIServices(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + { + name: "nil yields required only", + in: nil, + want: []string{"HandlerService", "LoggerService", "StatsService"}, + }, + { + name: "empty yields required only", + in: []string{}, + want: []string{"HandlerService", "LoggerService", "StatsService"}, + }, + { + name: "routing service appended", + in: []string{"routingservice"}, + want: []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"}, + }, + { + name: "dedupe and case fold", + in: []string{"HandlerService", "handlerservice", "ROUTINGSERVICE"}, + want: []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"}, + }, + { + name: "unknown dropped, required preserved", + in: []string{"foo", "RoutigService"}, + want: []string{"HandlerService", "LoggerService", "StatsService"}, + }, + { + name: "extras sorted deterministically", + in: []string{"routingservice", "observatoryservice", "reflectionservice"}, + want: []string{"HandlerService", "LoggerService", "StatsService", "ObservatoryService", "ReflectionService", "RoutingService"}, + }, + { + name: "whitespace trimmed", + in: []string{" routingservice "}, + want: []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeAPIServices(tc.in) + if !slices.Equal(got, tc.want) { + t.Fatalf("sanitizeAPIServices(%#v) = %#v, want %#v", tc.in, got, tc.want) + } + }) + } +} From 88bd3edbe2466cf978be8e6f9ad01dfd783a9608 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 12:40:01 +0330 Subject: [PATCH 02/20] refactor(xray): clarify API service drift comment and add dedupe test --- backend/xray/config.go | 6 ++++-- backend/xray/config_test.go | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/xray/config.go b/backend/xray/config.go index 3150b9e..7e419e8 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -529,8 +529,10 @@ func normalizeGeoIPPrivateRules(rules []json.RawMessage) ([]json.RawMessage, err // canonicalAPIServices maps a lowercased service name to its canonical form. // ponytail: mirrors the switch in xray-core infra/conf/api.go APIConfig.Build(). -// Ceiling: if xray-core adds/removes a service this must be synced by hand -// (TestSanitizeAPIServices pins the set so a drifted upgrade fails loudly). +// Ceiling: this set must be synced by hand against xray-core on upgrade — +// nothing detects drift automatically. TestSanitizeAPIServices pins the +// expected names, so a hand-edit to this map breaks the test and forces the +// expectations to be updated deliberately. var canonicalAPIServices = map[string]string{ "reflectionservice": "ReflectionService", "handlerservice": "HandlerService", diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go index 04bb89b..07b2412 100644 --- a/backend/xray/config_test.go +++ b/backend/xray/config_test.go @@ -31,6 +31,11 @@ func TestSanitizeAPIServices(t *testing.T) { in: []string{"HandlerService", "handlerservice", "ROUTINGSERVICE"}, want: []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"}, }, + { + name: "dedupe extras across case", + in: []string{"routingservice", "RoutingService"}, + want: []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"}, + }, { name: "unknown dropped, required preserved", in: []string{"foo", "RoutigService"}, From e86e18f6a372558fce08d76533b16acff4d8a989 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 12:54:31 +0330 Subject: [PATCH 03/20] feat(xray): respect user-provided API services in ApplyAPI --- backend/xray/config.go | 10 +++++++++- backend/xray/config_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/xray/config.go b/backend/xray/config.go index 7e419e8..3177e38 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -763,9 +763,17 @@ func (c *Config) ApplyAPI(apiPort, metricPort int) (err error) { apiTag := "API" + var userServices []string + if c.API != nil { + userServices = c.API.Services + } + c.API = &conf.APIConfig{ - Services: []string{"HandlerService", "LoggerService", "StatsService"}, + Services: sanitizeAPIServices(userServices), Tag: apiTag, + // Listen intentionally left empty: the node exposes the API only via the + // loopback, source-restricted API_INBOUND below. Honoring a user listen + // would open a second, unguarded gRPC entry point. } c.Metrics = map[string]any{ diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go index 07b2412..4f3ba5a 100644 --- a/backend/xray/config_test.go +++ b/backend/xray/config_test.go @@ -3,6 +3,8 @@ package xray import ( "slices" "testing" + + "github.com/xtls/xray-core/infra/conf" ) func TestSanitizeAPIServices(t *testing.T) { @@ -62,3 +64,29 @@ func TestSanitizeAPIServices(t *testing.T) { }) } } + +func TestApplyAPIMergesUserServices(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{ + Services: []string{"RoutingService"}, + Tag: "custom", + Listen: "1.2.3.4:5", + }, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + + want := []string{"HandlerService", "LoggerService", "StatsService", "RoutingService"} + if !slices.Equal(cfg.API.Services, want) { + t.Fatalf("API.Services = %#v, want %#v", cfg.API.Services, want) + } + if cfg.API.Tag != "API" { + t.Fatalf("API.Tag = %q, want %q", cfg.API.Tag, "API") + } + if cfg.API.Listen != "" { + t.Fatalf("API.Listen = %q, want empty (node forces loopback API_INBOUND only)", cfg.API.Listen) + } +} From 36e245c5e9591983efd5c6412204a6f594a75371 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 12:59:07 +0330 Subject: [PATCH 04/20] test(xray): assert ApplyAPI with no api section yields required services --- backend/xray/config_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go index 4f3ba5a..8d6b451 100644 --- a/backend/xray/config_test.go +++ b/backend/xray/config_test.go @@ -90,3 +90,16 @@ func TestApplyAPIMergesUserServices(t *testing.T) { t.Fatalf("API.Listen = %q, want empty (node forces loopback API_INBOUND only)", cfg.API.Listen) } } + +func TestApplyAPINilAPIYieldsRequiredServices(t *testing.T) { + cfg := &Config{InboundConfigs: []*Inbound{}} + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + + want := []string{"HandlerService", "LoggerService", "StatsService"} + if !slices.Equal(cfg.API.Services, want) { + t.Fatalf("API.Services = %#v, want %#v", cfg.API.Services, want) + } +} From b2cd2fdffa4554fc7ce670a7714b5ccd9d7c684f Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:44:53 +0330 Subject: [PATCH 05/20] chore: ignore local tooling artifacts (graphify-out, superpowers) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6b9765b..bc0f507 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ xray-core/ # AI .AI +graphify-out +superpowers/ certs/ generated/ From 0c72aeed6b52893f55e3310a9c35ed6df83b3a0a Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:24:10 +0330 Subject: [PATCH 06/20] fix(xray): inject default observatory when ObservatoryService enabled --- backend/xray/config.go | 39 ++++++++++++- backend/xray/config_test.go | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/backend/xray/config.go b/backend/xray/config.go index 3177e38..8199a75 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -586,6 +586,34 @@ func sanitizeAPIServices(userProvided []string) []string { return append(result, extras...) } +const ( + defaultObservatoryProbeURL = "https://www.google.com/generate_204" + defaultObservatoryProbeInterval = "10s" +) + +// defaultObservatory builds an observatory app that probes every eligible +// outbound (a tag whose protocol is not in observatoryExcludedProtocols). It is +// injected only when ObservatoryService is enabled but the operator configured +// no observatory/burstObservatory — without the observatory feature xray-core +// fails dependency resolution and the core exits. With no eligible outbounds it +// still returns a valid object so the dependency resolves. +func (c *Config) defaultObservatory() map[string]any { + protocolByTag := c.outboundProtocolByTag() + tags := make([]string, 0, len(protocolByTag)) + for tag, protocol := range protocolByTag { + if _, excluded := observatoryExcludedProtocols[protocol]; excluded { + continue + } + tags = append(tags, tag) + } + sort.Strings(tags) + return map[string]any{ + "subjectSelector": tags, + "probeURL": defaultObservatoryProbeURL, + "probeInterval": defaultObservatoryProbeInterval, + } +} + func apiRuleSources() []string { seen := map[string]struct{}{ "127.0.0.1": {}, @@ -768,14 +796,23 @@ func (c *Config) ApplyAPI(apiPort, metricPort int) (err error) { userServices = c.API.Services } + services := sanitizeAPIServices(userServices) c.API = &conf.APIConfig{ - Services: sanitizeAPIServices(userServices), + Services: services, Tag: apiTag, // Listen intentionally left empty: the node exposes the API only via the // loopback, source-restricted API_INBOUND below. Honoring a user listen // would open a second, unguarded gRPC entry point. } + // ObservatoryService has a hard dependency on the observatory feature; enabling + // it without an observatory/burstObservatory app makes xray exit on startup. + // Inject a functional default so the toggle is crash-free and immediately useful. + if slices.Contains(services, "ObservatoryService") && + c.Observatory == nil && c.BurstObservatory == nil { + c.Observatory = c.defaultObservatory() + } + c.Metrics = map[string]any{ "tag": "metric", "listen": loopbackListenAddress(metricPort), diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go index 8d6b451..48130b2 100644 --- a/backend/xray/config_test.go +++ b/backend/xray/config_test.go @@ -103,3 +103,114 @@ func TestApplyAPINilAPIYieldsRequiredServices(t *testing.T) { t.Fatalf("API.Services = %#v, want %#v", cfg.API.Services, want) } } + +func observatorySelector(t *testing.T, cfg *Config) ([]string, bool) { + t.Helper() + if cfg.Observatory == nil { + return nil, false + } + raw, ok := cfg.Observatory["subjectSelector"] + if !ok { + return []string{}, true + } + sel, ok := raw.([]string) + if !ok { + t.Fatalf("subjectSelector type = %T, want []string", raw) + } + return sel, true +} + +func TestApplyAPIInjectsObservatoryWhenServiceEnabled(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{Services: []string{"ObservatoryService"}}, + OutboundConfigs: []any{ + map[string]any{"tag": "proxy", "protocol": "vless"}, + map[string]any{"tag": "block", "protocol": "blackhole"}, + }, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + + sel, ok := observatorySelector(t, cfg) + if !ok { + t.Fatal("expected observatory injected, got nil") + } + if !slices.Equal(sel, []string{"proxy"}) { + t.Fatalf("subjectSelector = %#v, want [proxy] (blackhole excluded)", sel) + } + if cfg.Observatory["probeURL"] != defaultObservatoryProbeURL { + t.Fatalf("probeURL = %v, want %q", cfg.Observatory["probeURL"], defaultObservatoryProbeURL) + } + if cfg.Observatory["probeInterval"] != defaultObservatoryProbeInterval { + t.Fatalf("probeInterval = %v, want %q", cfg.Observatory["probeInterval"], defaultObservatoryProbeInterval) + } +} + +func TestApplyAPINoObservatoryWhenServiceDisabled(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{Services: []string{"RoutingService"}}, + OutboundConfigs: []any{map[string]any{"tag": "proxy", "protocol": "vless"}}, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + if cfg.Observatory != nil { + t.Fatalf("observatory = %#v, want nil (ObservatoryService not enabled)", cfg.Observatory) + } +} + +func TestApplyAPIRespectsUserObservatory(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{Services: []string{"ObservatoryService"}}, + OutboundConfigs: []any{map[string]any{"tag": "proxy", "protocol": "vless"}}, + Observatory: map[string]any{"subjectSelector": []any{"proxy"}, "probeURL": "https://example.com"}, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + if cfg.Observatory["probeURL"] != "https://example.com" { + t.Fatalf("probeURL = %v, want user value preserved", cfg.Observatory["probeURL"]) + } +} + +func TestApplyAPIRespectsUserBurstObservatory(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{Services: []string{"ObservatoryService"}}, + OutboundConfigs: []any{map[string]any{"tag": "proxy", "protocol": "vless"}}, + BurstObservatory: map[string]any{"subjectSelector": []any{"proxy"}}, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + if cfg.Observatory != nil { + t.Fatalf("observatory = %#v, want nil (burstObservatory satisfies the dependency)", cfg.Observatory) + } +} + +func TestApplyAPIInjectsObservatoryWithNoEligibleOutbounds(t *testing.T) { + cfg := &Config{ + InboundConfigs: []*Inbound{}, + API: &conf.APIConfig{Services: []string{"ObservatoryService"}}, + OutboundConfigs: []any{map[string]any{"tag": "block", "protocol": "blackhole"}}, + } + + if err := cfg.ApplyAPI(10001, 10002); err != nil { + t.Fatal(err) + } + sel, ok := observatorySelector(t, cfg) + if !ok { + t.Fatal("expected observatory injected even with no eligible outbounds") + } + if len(sel) != 0 { + t.Fatalf("subjectSelector = %#v, want empty", sel) + } +} From b98531a57f285cf4fc1a0e4a673a15e13c30bf30 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:34:30 +0330 Subject: [PATCH 07/20] feat(proto): add RoutingService RPCs to NodeService --- common/service.pb.go | 735 ++++++++++++++++++++++++++++++++++---- common/service.proto | 50 +++ common/service_grpc.pb.go | 228 ++++++++++++ 3 files changed, 949 insertions(+), 64 deletions(-) diff --git a/common/service.pb.go b/common/service.pb.go index 0ce968b..a47162b 100644 --- a/common/service.pb.go +++ b/common/service.pb.go @@ -1545,6 +1545,539 @@ func (x *UsersChunk) GetLast() bool { return false } +// Routing (mirrors xray app/router/command, node-friendly shapes) +type RoutingRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + OutboundTag string `protobuf:"bytes,1,opt,name=outbound_tag,json=outboundTag,proto3" json:"outbound_tag,omitempty"` + RuleTag string `protobuf:"bytes,2,opt,name=rule_tag,json=ruleTag,proto3" json:"rule_tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoutingRule) Reset() { + *x = RoutingRule{} + mi := &file_common_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoutingRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoutingRule) ProtoMessage() {} + +func (x *RoutingRule) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoutingRule.ProtoReflect.Descriptor instead. +func (*RoutingRule) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{24} +} + +func (x *RoutingRule) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +func (x *RoutingRule) GetRuleTag() string { + if x != nil { + return x.RuleTag + } + return "" +} + +type RoutingRulesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Rules []*RoutingRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoutingRulesResponse) Reset() { + *x = RoutingRulesResponse{} + mi := &file_common_service_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoutingRulesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoutingRulesResponse) ProtoMessage() {} + +func (x *RoutingRulesResponse) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoutingRulesResponse.ProtoReflect.Descriptor instead. +func (*RoutingRulesResponse) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{25} +} + +func (x *RoutingRulesResponse) GetRules() []*RoutingRule { + if x != nil { + return x.Rules + } + return nil +} + +type BalancerInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BalancerInfoRequest) Reset() { + *x = BalancerInfoRequest{} + mi := &file_common_service_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BalancerInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BalancerInfoRequest) ProtoMessage() {} + +func (x *BalancerInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BalancerInfoRequest.ProtoReflect.Descriptor instead. +func (*BalancerInfoRequest) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{26} +} + +func (x *BalancerInfoRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type BalancerInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + OverrideTarget string `protobuf:"bytes,1,opt,name=override_target,json=overrideTarget,proto3" json:"override_target,omitempty"` + PrincipleTarget []string `protobuf:"bytes,2,rep,name=principle_target,json=principleTarget,proto3" json:"principle_target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BalancerInfoResponse) Reset() { + *x = BalancerInfoResponse{} + mi := &file_common_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BalancerInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BalancerInfoResponse) ProtoMessage() {} + +func (x *BalancerInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BalancerInfoResponse.ProtoReflect.Descriptor instead. +func (*BalancerInfoResponse) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{27} +} + +func (x *BalancerInfoResponse) GetOverrideTarget() string { + if x != nil { + return x.OverrideTarget + } + return "" +} + +func (x *BalancerInfoResponse) GetPrincipleTarget() []string { + if x != nil { + return x.PrincipleTarget + } + return nil +} + +type TestRouteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InboundTag string `protobuf:"bytes,1,opt,name=inbound_tag,json=inboundTag,proto3" json:"inbound_tag,omitempty"` + Network string `protobuf:"bytes,2,opt,name=network,proto3" json:"network,omitempty"` // "tcp" | "udp" + TargetIp string `protobuf:"bytes,3,opt,name=target_ip,json=targetIp,proto3" json:"target_ip,omitempty"` + TargetDomain string `protobuf:"bytes,4,opt,name=target_domain,json=targetDomain,proto3" json:"target_domain,omitempty"` + TargetPort uint32 `protobuf:"varint,5,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + Protocol string `protobuf:"bytes,6,opt,name=protocol,proto3" json:"protocol,omitempty"` + User string `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"` + Attributes map[string]string `protobuf:"bytes,8,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + FieldSelectors []string `protobuf:"bytes,9,rep,name=field_selectors,json=fieldSelectors,proto3" json:"field_selectors,omitempty"` + PublishResult bool `protobuf:"varint,10,opt,name=publish_result,json=publishResult,proto3" json:"publish_result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestRouteRequest) Reset() { + *x = TestRouteRequest{} + mi := &file_common_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestRouteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestRouteRequest) ProtoMessage() {} + +func (x *TestRouteRequest) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestRouteRequest.ProtoReflect.Descriptor instead. +func (*TestRouteRequest) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{28} +} + +func (x *TestRouteRequest) GetInboundTag() string { + if x != nil { + return x.InboundTag + } + return "" +} + +func (x *TestRouteRequest) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *TestRouteRequest) GetTargetIp() string { + if x != nil { + return x.TargetIp + } + return "" +} + +func (x *TestRouteRequest) GetTargetDomain() string { + if x != nil { + return x.TargetDomain + } + return "" +} + +func (x *TestRouteRequest) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + +func (x *TestRouteRequest) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *TestRouteRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *TestRouteRequest) GetAttributes() map[string]string { + if x != nil { + return x.Attributes + } + return nil +} + +func (x *TestRouteRequest) GetFieldSelectors() []string { + if x != nil { + return x.FieldSelectors + } + return nil +} + +func (x *TestRouteRequest) GetPublishResult() bool { + if x != nil { + return x.PublishResult + } + return false +} + +type RouteResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + OutboundTag string `protobuf:"bytes,1,opt,name=outbound_tag,json=outboundTag,proto3" json:"outbound_tag,omitempty"` + OutboundGroupTags []string `protobuf:"bytes,2,rep,name=outbound_group_tags,json=outboundGroupTags,proto3" json:"outbound_group_tags,omitempty"` + InboundTag string `protobuf:"bytes,3,opt,name=inbound_tag,json=inboundTag,proto3" json:"inbound_tag,omitempty"` + Network string `protobuf:"bytes,4,opt,name=network,proto3" json:"network,omitempty"` + TargetDomain string `protobuf:"bytes,5,opt,name=target_domain,json=targetDomain,proto3" json:"target_domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RouteResult) Reset() { + *x = RouteResult{} + mi := &file_common_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RouteResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RouteResult) ProtoMessage() {} + +func (x *RouteResult) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RouteResult.ProtoReflect.Descriptor instead. +func (*RouteResult) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{29} +} + +func (x *RouteResult) GetOutboundTag() string { + if x != nil { + return x.OutboundTag + } + return "" +} + +func (x *RouteResult) GetOutboundGroupTags() []string { + if x != nil { + return x.OutboundGroupTags + } + return nil +} + +func (x *RouteResult) GetInboundTag() string { + if x != nil { + return x.InboundTag + } + return "" +} + +func (x *RouteResult) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *RouteResult) GetTargetDomain() string { + if x != nil { + return x.TargetDomain + } + return "" +} + +type AddRoutingRuleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Rule string `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` // JSON for one xray routing rule (same shape as routing.rules[]) + ShouldAppend bool `protobuf:"varint,2,opt,name=should_append,json=shouldAppend,proto3" json:"should_append,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddRoutingRuleRequest) Reset() { + *x = AddRoutingRuleRequest{} + mi := &file_common_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddRoutingRuleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddRoutingRuleRequest) ProtoMessage() {} + +func (x *AddRoutingRuleRequest) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddRoutingRuleRequest.ProtoReflect.Descriptor instead. +func (*AddRoutingRuleRequest) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{30} +} + +func (x *AddRoutingRuleRequest) GetRule() string { + if x != nil { + return x.Rule + } + return "" +} + +func (x *AddRoutingRuleRequest) GetShouldAppend() bool { + if x != nil { + return x.ShouldAppend + } + return false +} + +type RemoveRoutingRuleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleTag string `protobuf:"bytes,1,opt,name=rule_tag,json=ruleTag,proto3" json:"rule_tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRoutingRuleRequest) Reset() { + *x = RemoveRoutingRuleRequest{} + mi := &file_common_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRoutingRuleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRoutingRuleRequest) ProtoMessage() {} + +func (x *RemoveRoutingRuleRequest) ProtoReflect() protoreflect.Message { + mi := &file_common_service_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRoutingRuleRequest.ProtoReflect.Descriptor instead. +func (*RemoveRoutingRuleRequest) Descriptor() ([]byte, []int) { + return file_common_service_proto_rawDescGZIP(), []int{31} +} + +func (x *RemoveRoutingRuleRequest) GetRuleTag() string { + if x != nil { + return x.RuleTag + } + return "" +} + +type OverrideBalancerTargetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + BalancerTag string `protobuf:"bytes,1,opt,name=balancer_tag,json=balancerTag,proto3" json:"balancer_tag,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OverrideBalancerTargetRequest) Reset() { + *x = OverrideBalancerTargetRequest{} + mi := &file_common_service_proto_msgTypes[32] + 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_common_service_proto_msgTypes[32] + if 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_common_service_proto_rawDescGZIP(), []int{32} +} + +func (x *OverrideBalancerTargetRequest) GetBalancerTag() string { + if x != nil { + return x.BalancerTag + } + return "" +} + +func (x *OverrideBalancerTargetRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + var File_common_service_proto protoreflect.FileDescriptor const file_common_service_proto_rawDesc = "" + @@ -1650,7 +2183,51 @@ const file_common_service_proto_rawDesc = "" + "UsersChunk\x12#\n" + "\x05users\x18\x01 \x03(\v2\r.service.UserR\x05users\x12\x14\n" + "\x05index\x18\x02 \x01(\x04R\x05index\x12\x12\n" + - "\x04last\x18\x03 \x01(\bR\x04last*&\n" + + "\x04last\x18\x03 \x01(\bR\x04last\"K\n" + + "\vRoutingRule\x12!\n" + + "\foutbound_tag\x18\x01 \x01(\tR\voutboundTag\x12\x19\n" + + "\brule_tag\x18\x02 \x01(\tR\aruleTag\"B\n" + + "\x14RoutingRulesResponse\x12*\n" + + "\x05rules\x18\x01 \x03(\v2\x14.service.RoutingRuleR\x05rules\"'\n" + + "\x13BalancerInfoRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\"j\n" + + "\x14BalancerInfoResponse\x12'\n" + + "\x0foverride_target\x18\x01 \x01(\tR\x0eoverrideTarget\x12)\n" + + "\x10principle_target\x18\x02 \x03(\tR\x0fprincipleTarget\"\xba\x03\n" + + "\x10TestRouteRequest\x12\x1f\n" + + "\vinbound_tag\x18\x01 \x01(\tR\n" + + "inboundTag\x12\x18\n" + + "\anetwork\x18\x02 \x01(\tR\anetwork\x12\x1b\n" + + "\ttarget_ip\x18\x03 \x01(\tR\btargetIp\x12#\n" + + "\rtarget_domain\x18\x04 \x01(\tR\ftargetDomain\x12\x1f\n" + + "\vtarget_port\x18\x05 \x01(\rR\n" + + "targetPort\x12\x1a\n" + + "\bprotocol\x18\x06 \x01(\tR\bprotocol\x12\x12\n" + + "\x04user\x18\a \x01(\tR\x04user\x12I\n" + + "\n" + + "attributes\x18\b \x03(\v2).service.TestRouteRequest.AttributesEntryR\n" + + "attributes\x12'\n" + + "\x0ffield_selectors\x18\t \x03(\tR\x0efieldSelectors\x12%\n" + + "\x0epublish_result\x18\n" + + " \x01(\bR\rpublishResult\x1a=\n" + + "\x0fAttributesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc0\x01\n" + + "\vRouteResult\x12!\n" + + "\foutbound_tag\x18\x01 \x01(\tR\voutboundTag\x12.\n" + + "\x13outbound_group_tags\x18\x02 \x03(\tR\x11outboundGroupTags\x12\x1f\n" + + "\vinbound_tag\x18\x03 \x01(\tR\n" + + "inboundTag\x12\x18\n" + + "\anetwork\x18\x04 \x01(\tR\anetwork\x12#\n" + + "\rtarget_domain\x18\x05 \x01(\tR\ftargetDomain\"P\n" + + "\x15AddRoutingRuleRequest\x12\x12\n" + + "\x04rule\x18\x01 \x01(\tR\x04rule\x12#\n" + + "\rshould_append\x18\x02 \x01(\bR\fshouldAppend\"5\n" + + "\x18RemoveRoutingRuleRequest\x12\x19\n" + + "\brule_tag\x18\x01 \x01(\tR\aruleTag\"Z\n" + + "\x1dOverrideBalancerTargetRequest\x12!\n" + + "\fbalancer_tag\x18\x01 \x01(\tR\vbalancerTag\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target*&\n" + "\vBackendType\x12\b\n" + "\x04XRAY\x10\x00\x12\r\n" + "\tWIREGUARD\x10\x01*_\n" + @@ -1660,7 +2237,7 @@ const file_common_service_proto_rawDesc = "" + "\bInbounds\x10\x02\x12\v\n" + "\aInbound\x10\x03\x12\r\n" + "\tUsersStat\x10\x04\x12\f\n" + - "\bUserStat\x10\x052\xa3\x06\n" + + "\bUserStat\x10\x052\xdc\t\n" + "\vNodeService\x126\n" + "\x05Start\x12\x10.service.Backend\x1a\x19.service.BaseInfoResponse\"\x00\x12(\n" + "\x04Stop\x12\x0e.service.Empty\x1a\x0e.service.Empty\"\x00\x12:\n" + @@ -1674,7 +2251,13 @@ const file_common_service_proto_rawDesc = "" + "\x18GetUserOnlineIpListStats\x12\x14.service.StatRequest\x1a\".service.StatsOnlineIpListResponse\"\x00\x12-\n" + "\bSyncUser\x12\r.service.User\x1a\x0e.service.Empty\"\x00(\x01\x12-\n" + "\tSyncUsers\x12\x0e.service.Users\x1a\x0e.service.Empty\"\x00\x12;\n" + - "\x10SyncUsersChunked\x12\x13.service.UsersChunk\x1a\x0e.service.Empty\"\x00(\x01B#Z!github.com/pasarguard/node/commonb\x06proto3" + "\x10SyncUsersChunked\x12\x13.service.UsersChunk\x1a\x0e.service.Empty\"\x00(\x01\x12C\n" + + "\x10ListRoutingRules\x12\x0e.service.Empty\x1a\x1d.service.RoutingRulesResponse\"\x00\x12P\n" + + "\x0fGetBalancerInfo\x12\x1c.service.BalancerInfoRequest\x1a\x1d.service.BalancerInfoResponse\"\x00\x12>\n" + + "\tTestRoute\x12\x19.service.TestRouteRequest\x1a\x14.service.RouteResult\"\x00\x12B\n" + + "\x0eAddRoutingRule\x12\x1e.service.AddRoutingRuleRequest\x1a\x0e.service.Empty\"\x00\x12H\n" + + "\x11RemoveRoutingRule\x12!.service.RemoveRoutingRuleRequest\x1a\x0e.service.Empty\"\x00\x12R\n" + + "\x16OverrideBalancerTarget\x12&.service.OverrideBalancerTargetRequest\x1a\x0e.service.Empty\"\x00B#Z!github.com/pasarguard/node/commonb\x06proto3" var ( file_common_service_proto_rawDescOnce sync.Once @@ -1689,42 +2272,52 @@ func file_common_service_proto_rawDescGZIP() []byte { } var file_common_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_common_service_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_common_service_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_common_service_proto_goTypes = []any{ - (BackendType)(0), // 0: service.BackendType - (StatType)(0), // 1: service.StatType - (*Empty)(nil), // 2: service.Empty - (*BaseInfoResponse)(nil), // 3: service.BaseInfoResponse - (*Backend)(nil), // 4: service.Backend - (*Log)(nil), // 5: service.Log - (*Stat)(nil), // 6: service.Stat - (*StatResponse)(nil), // 7: service.StatResponse - (*StatRequest)(nil), // 8: service.StatRequest - (*OnlineStatResponse)(nil), // 9: service.OnlineStatResponse - (*StatsOnlineIpListResponse)(nil), // 10: service.StatsOnlineIpListResponse - (*Latency)(nil), // 11: service.Latency - (*LatencyRequest)(nil), // 12: service.LatencyRequest - (*LatencyResponse)(nil), // 13: service.LatencyResponse - (*BackendStatsResponse)(nil), // 14: service.BackendStatsResponse - (*SystemStatsResponse)(nil), // 15: service.SystemStatsResponse - (*Vmess)(nil), // 16: service.Vmess - (*Vless)(nil), // 17: service.Vless - (*Trojan)(nil), // 18: service.Trojan - (*Shadowsocks)(nil), // 19: service.Shadowsocks - (*Wireguard)(nil), // 20: service.Wireguard - (*Hysteria)(nil), // 21: service.Hysteria - (*Proxy)(nil), // 22: service.Proxy - (*User)(nil), // 23: service.User - (*Users)(nil), // 24: service.Users - (*UsersChunk)(nil), // 25: service.UsersChunk - nil, // 26: service.StatsOnlineIpListResponse.IpsEntry + (BackendType)(0), // 0: service.BackendType + (StatType)(0), // 1: service.StatType + (*Empty)(nil), // 2: service.Empty + (*BaseInfoResponse)(nil), // 3: service.BaseInfoResponse + (*Backend)(nil), // 4: service.Backend + (*Log)(nil), // 5: service.Log + (*Stat)(nil), // 6: service.Stat + (*StatResponse)(nil), // 7: service.StatResponse + (*StatRequest)(nil), // 8: service.StatRequest + (*OnlineStatResponse)(nil), // 9: service.OnlineStatResponse + (*StatsOnlineIpListResponse)(nil), // 10: service.StatsOnlineIpListResponse + (*Latency)(nil), // 11: service.Latency + (*LatencyRequest)(nil), // 12: service.LatencyRequest + (*LatencyResponse)(nil), // 13: service.LatencyResponse + (*BackendStatsResponse)(nil), // 14: service.BackendStatsResponse + (*SystemStatsResponse)(nil), // 15: service.SystemStatsResponse + (*Vmess)(nil), // 16: service.Vmess + (*Vless)(nil), // 17: service.Vless + (*Trojan)(nil), // 18: service.Trojan + (*Shadowsocks)(nil), // 19: service.Shadowsocks + (*Wireguard)(nil), // 20: service.Wireguard + (*Hysteria)(nil), // 21: service.Hysteria + (*Proxy)(nil), // 22: service.Proxy + (*User)(nil), // 23: service.User + (*Users)(nil), // 24: service.Users + (*UsersChunk)(nil), // 25: service.UsersChunk + (*RoutingRule)(nil), // 26: service.RoutingRule + (*RoutingRulesResponse)(nil), // 27: service.RoutingRulesResponse + (*BalancerInfoRequest)(nil), // 28: service.BalancerInfoRequest + (*BalancerInfoResponse)(nil), // 29: service.BalancerInfoResponse + (*TestRouteRequest)(nil), // 30: service.TestRouteRequest + (*RouteResult)(nil), // 31: service.RouteResult + (*AddRoutingRuleRequest)(nil), // 32: service.AddRoutingRuleRequest + (*RemoveRoutingRuleRequest)(nil), // 33: service.RemoveRoutingRuleRequest + (*OverrideBalancerTargetRequest)(nil), // 34: service.OverrideBalancerTargetRequest + nil, // 35: service.StatsOnlineIpListResponse.IpsEntry + nil, // 36: service.TestRouteRequest.AttributesEntry } var file_common_service_proto_depIdxs = []int32{ 0, // 0: service.Backend.type:type_name -> service.BackendType 23, // 1: service.Backend.users:type_name -> service.User 6, // 2: service.StatResponse.stats:type_name -> service.Stat 1, // 3: service.StatRequest.type:type_name -> service.StatType - 26, // 4: service.StatsOnlineIpListResponse.ips:type_name -> service.StatsOnlineIpListResponse.IpsEntry + 35, // 4: service.StatsOnlineIpListResponse.ips:type_name -> service.StatsOnlineIpListResponse.IpsEntry 11, // 5: service.LatencyResponse.latencies:type_name -> service.Latency 16, // 6: service.Proxy.vmess:type_name -> service.Vmess 17, // 7: service.Proxy.vless:type_name -> service.Vless @@ -1735,37 +2328,51 @@ var file_common_service_proto_depIdxs = []int32{ 22, // 12: service.User.proxies:type_name -> service.Proxy 23, // 13: service.Users.users:type_name -> service.User 23, // 14: service.UsersChunk.users:type_name -> service.User - 4, // 15: service.NodeService.Start:input_type -> service.Backend - 2, // 16: service.NodeService.Stop:input_type -> service.Empty - 2, // 17: service.NodeService.GetBaseInfo:input_type -> service.Empty - 2, // 18: service.NodeService.GetLogs:input_type -> service.Empty - 2, // 19: service.NodeService.GetSystemStats:input_type -> service.Empty - 2, // 20: service.NodeService.GetBackendStats:input_type -> service.Empty - 8, // 21: service.NodeService.GetStats:input_type -> service.StatRequest - 12, // 22: service.NodeService.GetOutboundsLatency:input_type -> service.LatencyRequest - 8, // 23: service.NodeService.GetUserOnlineStats:input_type -> service.StatRequest - 8, // 24: service.NodeService.GetUserOnlineIpListStats:input_type -> service.StatRequest - 23, // 25: service.NodeService.SyncUser:input_type -> service.User - 24, // 26: service.NodeService.SyncUsers:input_type -> service.Users - 25, // 27: service.NodeService.SyncUsersChunked:input_type -> service.UsersChunk - 3, // 28: service.NodeService.Start:output_type -> service.BaseInfoResponse - 2, // 29: service.NodeService.Stop:output_type -> service.Empty - 3, // 30: service.NodeService.GetBaseInfo:output_type -> service.BaseInfoResponse - 5, // 31: service.NodeService.GetLogs:output_type -> service.Log - 15, // 32: service.NodeService.GetSystemStats:output_type -> service.SystemStatsResponse - 14, // 33: service.NodeService.GetBackendStats:output_type -> service.BackendStatsResponse - 7, // 34: service.NodeService.GetStats:output_type -> service.StatResponse - 13, // 35: service.NodeService.GetOutboundsLatency:output_type -> service.LatencyResponse - 9, // 36: service.NodeService.GetUserOnlineStats:output_type -> service.OnlineStatResponse - 10, // 37: service.NodeService.GetUserOnlineIpListStats:output_type -> service.StatsOnlineIpListResponse - 2, // 38: service.NodeService.SyncUser:output_type -> service.Empty - 2, // 39: service.NodeService.SyncUsers:output_type -> service.Empty - 2, // 40: service.NodeService.SyncUsersChunked:output_type -> service.Empty - 28, // [28:41] is the sub-list for method output_type - 15, // [15:28] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 26, // 15: service.RoutingRulesResponse.rules:type_name -> service.RoutingRule + 36, // 16: service.TestRouteRequest.attributes:type_name -> service.TestRouteRequest.AttributesEntry + 4, // 17: service.NodeService.Start:input_type -> service.Backend + 2, // 18: service.NodeService.Stop:input_type -> service.Empty + 2, // 19: service.NodeService.GetBaseInfo:input_type -> service.Empty + 2, // 20: service.NodeService.GetLogs:input_type -> service.Empty + 2, // 21: service.NodeService.GetSystemStats:input_type -> service.Empty + 2, // 22: service.NodeService.GetBackendStats:input_type -> service.Empty + 8, // 23: service.NodeService.GetStats:input_type -> service.StatRequest + 12, // 24: service.NodeService.GetOutboundsLatency:input_type -> service.LatencyRequest + 8, // 25: service.NodeService.GetUserOnlineStats:input_type -> service.StatRequest + 8, // 26: service.NodeService.GetUserOnlineIpListStats:input_type -> service.StatRequest + 23, // 27: service.NodeService.SyncUser:input_type -> service.User + 24, // 28: service.NodeService.SyncUsers:input_type -> service.Users + 25, // 29: service.NodeService.SyncUsersChunked:input_type -> service.UsersChunk + 2, // 30: service.NodeService.ListRoutingRules:input_type -> service.Empty + 28, // 31: service.NodeService.GetBalancerInfo:input_type -> service.BalancerInfoRequest + 30, // 32: service.NodeService.TestRoute:input_type -> service.TestRouteRequest + 32, // 33: service.NodeService.AddRoutingRule:input_type -> service.AddRoutingRuleRequest + 33, // 34: service.NodeService.RemoveRoutingRule:input_type -> service.RemoveRoutingRuleRequest + 34, // 35: service.NodeService.OverrideBalancerTarget:input_type -> service.OverrideBalancerTargetRequest + 3, // 36: service.NodeService.Start:output_type -> service.BaseInfoResponse + 2, // 37: service.NodeService.Stop:output_type -> service.Empty + 3, // 38: service.NodeService.GetBaseInfo:output_type -> service.BaseInfoResponse + 5, // 39: service.NodeService.GetLogs:output_type -> service.Log + 15, // 40: service.NodeService.GetSystemStats:output_type -> service.SystemStatsResponse + 14, // 41: service.NodeService.GetBackendStats:output_type -> service.BackendStatsResponse + 7, // 42: service.NodeService.GetStats:output_type -> service.StatResponse + 13, // 43: service.NodeService.GetOutboundsLatency:output_type -> service.LatencyResponse + 9, // 44: service.NodeService.GetUserOnlineStats:output_type -> service.OnlineStatResponse + 10, // 45: service.NodeService.GetUserOnlineIpListStats:output_type -> service.StatsOnlineIpListResponse + 2, // 46: service.NodeService.SyncUser:output_type -> service.Empty + 2, // 47: service.NodeService.SyncUsers:output_type -> service.Empty + 2, // 48: service.NodeService.SyncUsersChunked:output_type -> service.Empty + 27, // 49: service.NodeService.ListRoutingRules:output_type -> service.RoutingRulesResponse + 29, // 50: service.NodeService.GetBalancerInfo:output_type -> service.BalancerInfoResponse + 31, // 51: service.NodeService.TestRoute:output_type -> service.RouteResult + 2, // 52: service.NodeService.AddRoutingRule:output_type -> service.Empty + 2, // 53: service.NodeService.RemoveRoutingRule:output_type -> service.Empty + 2, // 54: service.NodeService.OverrideBalancerTarget:output_type -> service.Empty + 36, // [36:55] is the sub-list for method output_type + 17, // [17:36] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_common_service_proto_init() } @@ -1779,7 +2386,7 @@ func file_common_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_service_proto_rawDesc), len(file_common_service_proto_rawDesc)), NumEnums: 2, - NumMessages: 25, + NumMessages: 35, NumExtensions: 0, NumServices: 1, }, diff --git a/common/service.proto b/common/service.proto index 7ecfa6d..5c0f398 100644 --- a/common/service.proto +++ b/common/service.proto @@ -162,6 +162,49 @@ message UsersChunk { bool last = 3; } +// Routing (mirrors xray app/router/command, node-friendly shapes) +message RoutingRule { + string outbound_tag = 1; + string rule_tag = 2; +} +message RoutingRulesResponse { repeated RoutingRule rules = 1; } + +message BalancerInfoRequest { string tag = 1; } +message BalancerInfoResponse { + string override_target = 1; + repeated string principle_target = 2; +} + +message TestRouteRequest { + string inbound_tag = 1; + string network = 2; // "tcp" | "udp" + string target_ip = 3; + string target_domain = 4; + uint32 target_port = 5; + string protocol = 6; + string user = 7; + map attributes = 8; + repeated string field_selectors = 9; + bool publish_result = 10; +} +message RouteResult { + string outbound_tag = 1; + repeated string outbound_group_tags = 2; + string inbound_tag = 3; + string network = 4; + string target_domain = 5; +} + +message AddRoutingRuleRequest { + string rule = 1; // JSON for one xray routing rule (same shape as routing.rules[]) + bool should_append = 2; +} +message RemoveRoutingRuleRequest { string rule_tag = 1; } +message OverrideBalancerTargetRequest { + string balancer_tag = 1; + string target = 2; +} + // Service for node management and connection service NodeService { rpc Start (Backend) returns (BaseInfoResponse) {} @@ -182,4 +225,11 @@ service NodeService { rpc SyncUser (stream User) returns (Empty) {} rpc SyncUsers (Users) returns (Empty) {} rpc SyncUsersChunked (stream UsersChunk) returns (Empty) {} + + rpc ListRoutingRules (Empty) returns (RoutingRulesResponse) {} + rpc GetBalancerInfo (BalancerInfoRequest) returns (BalancerInfoResponse) {} + rpc TestRoute (TestRouteRequest) returns (RouteResult) {} + rpc AddRoutingRule (AddRoutingRuleRequest) returns (Empty) {} + rpc RemoveRoutingRule (RemoveRoutingRuleRequest) returns (Empty) {} + rpc OverrideBalancerTarget (OverrideBalancerTargetRequest) returns (Empty) {} } diff --git a/common/service_grpc.pb.go b/common/service_grpc.pb.go index d99fdf5..86ea1c5 100644 --- a/common/service_grpc.pb.go +++ b/common/service_grpc.pb.go @@ -32,6 +32,12 @@ const ( NodeService_SyncUser_FullMethodName = "/service.NodeService/SyncUser" NodeService_SyncUsers_FullMethodName = "/service.NodeService/SyncUsers" NodeService_SyncUsersChunked_FullMethodName = "/service.NodeService/SyncUsersChunked" + NodeService_ListRoutingRules_FullMethodName = "/service.NodeService/ListRoutingRules" + NodeService_GetBalancerInfo_FullMethodName = "/service.NodeService/GetBalancerInfo" + NodeService_TestRoute_FullMethodName = "/service.NodeService/TestRoute" + NodeService_AddRoutingRule_FullMethodName = "/service.NodeService/AddRoutingRule" + NodeService_RemoveRoutingRule_FullMethodName = "/service.NodeService/RemoveRoutingRule" + NodeService_OverrideBalancerTarget_FullMethodName = "/service.NodeService/OverrideBalancerTarget" ) // NodeServiceClient is the client API for NodeService service. @@ -53,6 +59,12 @@ type NodeServiceClient interface { SyncUser(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[User, Empty], error) SyncUsers(ctx context.Context, in *Users, opts ...grpc.CallOption) (*Empty, error) SyncUsersChunked(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[UsersChunk, Empty], error) + ListRoutingRules(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*RoutingRulesResponse, error) + GetBalancerInfo(ctx context.Context, in *BalancerInfoRequest, opts ...grpc.CallOption) (*BalancerInfoResponse, error) + TestRoute(ctx context.Context, in *TestRouteRequest, opts ...grpc.CallOption) (*RouteResult, error) + AddRoutingRule(ctx context.Context, in *AddRoutingRuleRequest, opts ...grpc.CallOption) (*Empty, error) + RemoveRoutingRule(ctx context.Context, in *RemoveRoutingRuleRequest, opts ...grpc.CallOption) (*Empty, error) + OverrideBalancerTarget(ctx context.Context, in *OverrideBalancerTargetRequest, opts ...grpc.CallOption) (*Empty, error) } type nodeServiceClient struct { @@ -208,6 +220,66 @@ func (c *nodeServiceClient) SyncUsersChunked(ctx context.Context, opts ...grpc.C // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type NodeService_SyncUsersChunkedClient = grpc.ClientStreamingClient[UsersChunk, Empty] +func (c *nodeServiceClient) ListRoutingRules(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*RoutingRulesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RoutingRulesResponse) + err := c.cc.Invoke(ctx, NodeService_ListRoutingRules_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nodeServiceClient) GetBalancerInfo(ctx context.Context, in *BalancerInfoRequest, opts ...grpc.CallOption) (*BalancerInfoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BalancerInfoResponse) + err := c.cc.Invoke(ctx, NodeService_GetBalancerInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nodeServiceClient) TestRoute(ctx context.Context, in *TestRouteRequest, opts ...grpc.CallOption) (*RouteResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RouteResult) + err := c.cc.Invoke(ctx, NodeService_TestRoute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nodeServiceClient) AddRoutingRule(ctx context.Context, in *AddRoutingRuleRequest, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, NodeService_AddRoutingRule_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nodeServiceClient) RemoveRoutingRule(ctx context.Context, in *RemoveRoutingRuleRequest, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, NodeService_RemoveRoutingRule_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *nodeServiceClient) OverrideBalancerTarget(ctx context.Context, in *OverrideBalancerTargetRequest, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, NodeService_OverrideBalancerTarget_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // NodeServiceServer is the server API for NodeService service. // All implementations must embed UnimplementedNodeServiceServer // for forward compatibility. @@ -227,6 +299,12 @@ type NodeServiceServer interface { SyncUser(grpc.ClientStreamingServer[User, Empty]) error SyncUsers(context.Context, *Users) (*Empty, error) SyncUsersChunked(grpc.ClientStreamingServer[UsersChunk, Empty]) error + ListRoutingRules(context.Context, *Empty) (*RoutingRulesResponse, error) + GetBalancerInfo(context.Context, *BalancerInfoRequest) (*BalancerInfoResponse, error) + TestRoute(context.Context, *TestRouteRequest) (*RouteResult, error) + AddRoutingRule(context.Context, *AddRoutingRuleRequest) (*Empty, error) + RemoveRoutingRule(context.Context, *RemoveRoutingRuleRequest) (*Empty, error) + OverrideBalancerTarget(context.Context, *OverrideBalancerTargetRequest) (*Empty, error) mustEmbedUnimplementedNodeServiceServer() } @@ -276,6 +354,24 @@ func (UnimplementedNodeServiceServer) SyncUsers(context.Context, *Users) (*Empty func (UnimplementedNodeServiceServer) SyncUsersChunked(grpc.ClientStreamingServer[UsersChunk, Empty]) error { return status.Error(codes.Unimplemented, "method SyncUsersChunked not implemented") } +func (UnimplementedNodeServiceServer) ListRoutingRules(context.Context, *Empty) (*RoutingRulesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListRoutingRules not implemented") +} +func (UnimplementedNodeServiceServer) GetBalancerInfo(context.Context, *BalancerInfoRequest) (*BalancerInfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetBalancerInfo not implemented") +} +func (UnimplementedNodeServiceServer) TestRoute(context.Context, *TestRouteRequest) (*RouteResult, error) { + return nil, status.Error(codes.Unimplemented, "method TestRoute not implemented") +} +func (UnimplementedNodeServiceServer) AddRoutingRule(context.Context, *AddRoutingRuleRequest) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AddRoutingRule not implemented") +} +func (UnimplementedNodeServiceServer) RemoveRoutingRule(context.Context, *RemoveRoutingRuleRequest) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveRoutingRule not implemented") +} +func (UnimplementedNodeServiceServer) OverrideBalancerTarget(context.Context, *OverrideBalancerTargetRequest) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method OverrideBalancerTarget not implemented") +} func (UnimplementedNodeServiceServer) mustEmbedUnimplementedNodeServiceServer() {} func (UnimplementedNodeServiceServer) testEmbeddedByValue() {} @@ -502,6 +598,114 @@ func _NodeService_SyncUsersChunked_Handler(srv interface{}, stream grpc.ServerSt // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type NodeService_SyncUsersChunkedServer = grpc.ClientStreamingServer[UsersChunk, Empty] +func _NodeService_ListRoutingRules_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NodeServiceServer).ListRoutingRules(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_ListRoutingRules_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).ListRoutingRules(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _NodeService_GetBalancerInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BalancerInfoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NodeServiceServer).GetBalancerInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_GetBalancerInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).GetBalancerInfo(ctx, req.(*BalancerInfoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NodeService_TestRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TestRouteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NodeServiceServer).TestRoute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_TestRoute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).TestRoute(ctx, req.(*TestRouteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NodeService_AddRoutingRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddRoutingRuleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NodeServiceServer).AddRoutingRule(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_AddRoutingRule_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).AddRoutingRule(ctx, req.(*AddRoutingRuleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NodeService_RemoveRoutingRule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveRoutingRuleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NodeServiceServer).RemoveRoutingRule(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_RemoveRoutingRule_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).RemoveRoutingRule(ctx, req.(*RemoveRoutingRuleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _NodeService_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.(NodeServiceServer).OverrideBalancerTarget(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: NodeService_OverrideBalancerTarget_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NodeServiceServer).OverrideBalancerTarget(ctx, req.(*OverrideBalancerTargetRequest)) + } + return interceptor(ctx, in, info, handler) +} + // NodeService_ServiceDesc is the grpc.ServiceDesc for NodeService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -549,6 +753,30 @@ var NodeService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SyncUsers", Handler: _NodeService_SyncUsers_Handler, }, + { + MethodName: "ListRoutingRules", + Handler: _NodeService_ListRoutingRules_Handler, + }, + { + MethodName: "GetBalancerInfo", + Handler: _NodeService_GetBalancerInfo_Handler, + }, + { + MethodName: "TestRoute", + Handler: _NodeService_TestRoute_Handler, + }, + { + MethodName: "AddRoutingRule", + Handler: _NodeService_AddRoutingRule_Handler, + }, + { + MethodName: "RemoveRoutingRule", + Handler: _NodeService_RemoveRoutingRule_Handler, + }, + { + MethodName: "OverrideBalancerTarget", + Handler: _NodeService_OverrideBalancerTarget_Handler, + }, }, Streams: []grpc.StreamDesc{ { From d1ed408e08c8231983f4a03a0e4502822d4e731d Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:39:54 +0330 Subject: [PATCH 08/20] feat(backend): add RoutingBackend capability interface --- backend/backend.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/backend.go b/backend/backend.go index cc055a7..8c52335 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -23,6 +23,18 @@ type Backend interface { GetUserOnlineIpListStats(context.Context, string) (*common.StatsOnlineIpListResponse, error) } +// RoutingBackend is implemented by backends that expose xray RoutingService. +// Only *xray.Xray implements it; controller handlers type-assert it and return +// codes.Unimplemented for backends that do not (e.g. WireGuard). +type RoutingBackend interface { + ListRoutingRules(ctx context.Context) (*common.RoutingRulesResponse, error) + GetBalancerInfo(ctx context.Context, tag string) (*common.BalancerInfoResponse, error) + TestRoute(ctx context.Context, req *common.TestRouteRequest) (*common.RouteResult, error) + AddRoutingRule(ctx context.Context, ruleJSON string, shouldAppend bool) error + RemoveRoutingRule(ctx context.Context, ruleTag string) error + OverrideBalancerTarget(ctx context.Context, balancerTag, target string) error +} + type ConfigKey struct{} type UsersKey struct{} From 7181254ce76a9c9c14ac5efd9ccacc9a1018c9b7 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:41:14 +0330 Subject: [PATCH 09/20] feat(xray/api): dial RoutingServiceClient --- backend/xray/api/base.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/xray/api/base.go b/backend/xray/api/base.go index dd6a83f..8fce70a 100644 --- a/backend/xray/api/base.go +++ b/backend/xray/api/base.go @@ -7,6 +7,7 @@ import ( "time" "github.com/xtls/xray-core/app/proxyman/command" + routingService "github.com/xtls/xray-core/app/router/command" statsService "github.com/xtls/xray-core/app/stats/command" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -15,6 +16,7 @@ import ( type XrayHandler struct { HandlerServiceClient *command.HandlerServiceClient StatsServiceClient *statsService.StatsServiceClient + RoutingServiceClient *routingService.RoutingServiceClient GrpcClient *grpc.ClientConn } @@ -55,6 +57,8 @@ func NewXrayAPI(apiPort int) (*XrayHandler, error) { ssClient := statsService.NewStatsServiceClient(x.GrpcClient) x.HandlerServiceClient = &hsClient x.StatsServiceClient = &ssClient + rsClient := routingService.NewRoutingServiceClient(x.GrpcClient) + x.RoutingServiceClient = &rsClient return x, nil } @@ -64,5 +68,6 @@ func (x *XrayHandler) Close() { _ = x.GrpcClient.Close() } x.StatsServiceClient = nil + x.RoutingServiceClient = nil x.HandlerServiceClient = nil } From 81a5ff9d85ee691c46be630472903d557d7df2ab Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:43:13 +0330 Subject: [PATCH 10/20] feat(xray/api): map RoutingService calls to node types --- backend/xray/api/routing.go | 148 +++++++++++++++++++++++++++++++ backend/xray/api/routing_test.go | 97 ++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 backend/xray/api/routing.go create mode 100644 backend/xray/api/routing_test.go diff --git a/backend/xray/api/routing.go b/backend/xray/api/routing.go new file mode 100644 index 0000000..700c005 --- /dev/null +++ b/backend/xray/api/routing.go @@ -0,0 +1,148 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + + "github.com/pasarguard/node/common" + routingCommand "github.com/xtls/xray-core/app/router/command" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/infra/conf" +) + +func toCommonRoutingRules(resp *routingCommand.ListRuleResponse) *common.RoutingRulesResponse { + out := &common.RoutingRulesResponse{} + for _, r := range resp.GetRules() { + out.Rules = append(out.Rules, &common.RoutingRule{ + OutboundTag: r.GetTag(), + RuleTag: r.GetRuleTag(), + }) + } + return out +} + +func toCommonBalancerInfo(resp *routingCommand.GetBalancerInfoResponse) *common.BalancerInfoResponse { + out := &common.BalancerInfoResponse{} + if b := resp.GetBalancer(); b != nil { + if o := b.GetOverride(); o != nil { + out.OverrideTarget = o.GetTarget() + } + if p := b.GetPrincipleTarget(); p != nil { + out.PrincipleTarget = p.GetTag() + } + } + return out +} + +func networkFromString(s string) xnet.Network { + switch strings.ToLower(s) { + case "udp": + return xnet.Network_UDP + case "tcp": + return xnet.Network_TCP + default: + return xnet.Network_Unknown + } +} + +func buildTestRouteRequest(req *common.TestRouteRequest) *routingCommand.TestRouteRequest { + rc := &routingCommand.RoutingContext{ + InboundTag: req.GetInboundTag(), + Network: networkFromString(req.GetNetwork()), + TargetDomain: req.GetTargetDomain(), + TargetPort: req.GetTargetPort(), + Protocol: req.GetProtocol(), + User: req.GetUser(), + Attributes: req.GetAttributes(), + } + if ip := net.ParseIP(req.GetTargetIp()); ip != nil { + rc.TargetIPs = [][]byte{ip} + } + return &routingCommand.TestRouteRequest{ + RoutingContext: rc, + FieldSelectors: req.GetFieldSelectors(), + PublishResult: req.GetPublishResult(), + } +} + +func toCommonRouteResult(rc *routingCommand.RoutingContext) *common.RouteResult { + return &common.RouteResult{ + OutboundTag: rc.GetOutboundTag(), + OutboundGroupTags: rc.GetOutboundGroupTags(), + InboundTag: rc.GetInboundTag(), + Network: strings.ToLower(rc.GetNetwork().String()), + TargetDomain: rc.GetTargetDomain(), + } +} + +// addRuleConfig parses one routing-rule JSON (same shape as routing.rules[]) into +// the TypedMessage that RoutingService.AddRule expects, using only exported +// xray-core API. +func addRuleConfig(ruleJSON string) (*serial.TypedMessage, error) { + rc := &conf.RouterConfig{RuleList: []json.RawMessage{json.RawMessage(ruleJSON)}} + built, err := rc.Build() + if err != nil { + return nil, fmt.Errorf("parse routing rule: %w", err) + } + if len(built.Rule) == 0 { + return nil, errors.New("no routing rule parsed from JSON") + } + return serial.ToTypedMessage(built.Rule[0]), nil +} + +func (x *XrayHandler) ListRoutingRules(ctx context.Context) (*common.RoutingRulesResponse, error) { + client := *x.RoutingServiceClient + resp, err := client.ListRule(ctx, &routingCommand.ListRuleRequest{}) + if err != nil { + return nil, err + } + return toCommonRoutingRules(resp), nil +} + +func (x *XrayHandler) GetBalancerInfo(ctx context.Context, tag string) (*common.BalancerInfoResponse, error) { + client := *x.RoutingServiceClient + resp, err := client.GetBalancerInfo(ctx, &routingCommand.GetBalancerInfoRequest{Tag: tag}) + if err != nil { + return nil, err + } + return toCommonBalancerInfo(resp), nil +} + +func (x *XrayHandler) TestRoute(ctx context.Context, req *common.TestRouteRequest) (*common.RouteResult, error) { + client := *x.RoutingServiceClient + resp, err := client.TestRoute(ctx, buildTestRouteRequest(req)) + if err != nil { + return nil, err + } + return toCommonRouteResult(resp), nil +} + +func (x *XrayHandler) AddRoutingRule(ctx context.Context, ruleJSON string, shouldAppend bool) error { + tm, err := addRuleConfig(ruleJSON) + if err != nil { + return err + } + client := *x.RoutingServiceClient + _, err = client.AddRule(ctx, &routingCommand.AddRuleRequest{Config: tm, ShouldAppend: shouldAppend}) + return err +} + +func (x *XrayHandler) RemoveRoutingRule(ctx context.Context, ruleTag string) error { + client := *x.RoutingServiceClient + _, err := client.RemoveRule(ctx, &routingCommand.RemoveRuleRequest{RuleTag: ruleTag}) + return err +} + +func (x *XrayHandler) OverrideBalancerTarget(ctx context.Context, balancerTag, target string) error { + client := *x.RoutingServiceClient + _, err := client.OverrideBalancerTarget(ctx, &routingCommand.OverrideBalancerTargetRequest{ + BalancerTag: balancerTag, + Target: target, + }) + return err +} diff --git a/backend/xray/api/routing_test.go b/backend/xray/api/routing_test.go new file mode 100644 index 0000000..690a5cd --- /dev/null +++ b/backend/xray/api/routing_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + + "github.com/pasarguard/node/common" + routingCommand "github.com/xtls/xray-core/app/router/command" + xnet "github.com/xtls/xray-core/common/net" +) + +func TestToCommonRoutingRules(t *testing.T) { + in := &routingCommand.ListRuleResponse{ + Rules: []*routingCommand.ListRuleItem{ + {Tag: "direct", RuleTag: "r1"}, + {Tag: "proxy", RuleTag: ""}, + }, + } + got := toCommonRoutingRules(in) + if len(got.GetRules()) != 2 { + t.Fatalf("rules len = %d, want 2", len(got.GetRules())) + } + if got.Rules[0].GetOutboundTag() != "direct" || got.Rules[0].GetRuleTag() != "r1" { + t.Fatalf("rule[0] = %+v", got.Rules[0]) + } +} + +func TestToCommonBalancerInfo(t *testing.T) { + in := &routingCommand.GetBalancerInfoResponse{ + Balancer: &routingCommand.BalancerMsg{ + Override: &routingCommand.OverrideInfo{Target: "out-a"}, + PrincipleTarget: &routingCommand.PrincipleTargetInfo{Tag: []string{"out-a", "out-b"}}, + }, + } + got := toCommonBalancerInfo(in) + if got.GetOverrideTarget() != "out-a" { + t.Fatalf("override = %q, want out-a", got.GetOverrideTarget()) + } + if len(got.GetPrincipleTarget()) != 2 { + t.Fatalf("principle len = %d, want 2", len(got.GetPrincipleTarget())) + } +} + +func TestBuildTestRouteRequest(t *testing.T) { + req := buildTestRouteRequest(&common.TestRouteRequest{ + InboundTag: "in", + Network: "udp", + TargetIp: "1.1.1.1", + TargetDomain: "example.com", + TargetPort: 443, + Protocol: "tls", + User: "u", + Attributes: map[string]string{"k": "v"}, + PublishResult: true, + }) + if req.RoutingContext.Network != xnet.Network_UDP { + t.Fatalf("network = %v, want UDP", req.RoutingContext.Network) + } + if req.RoutingContext.TargetPort != 443 { + t.Fatalf("port = %d, want 443", req.RoutingContext.TargetPort) + } + if len(req.RoutingContext.TargetIPs) != 1 { + t.Fatalf("targetIPs = %v, want one entry", req.RoutingContext.TargetIPs) + } + if !req.PublishResult { + t.Fatal("publishResult not forwarded") + } +} + +func TestToCommonRouteResult(t *testing.T) { + rc := &routingCommand.RoutingContext{ + OutboundTag: "proxy", + OutboundGroupTags: []string{"g1"}, + InboundTag: "in", + Network: xnet.Network_TCP, + TargetDomain: "example.com", + } + got := toCommonRouteResult(rc) + if got.GetOutboundTag() != "proxy" || got.GetNetwork() != "tcp" { + t.Fatalf("result = %+v", got) + } +} + +func TestAddRuleConfigParsesJSON(t *testing.T) { + tm, err := addRuleConfig(`{"type":"field","outboundTag":"direct","domain":["example.com"],"ruleTag":"r1"}`) + if err != nil { + t.Fatalf("addRuleConfig error: %v", err) + } + if tm == nil || tm.Type == "" { + t.Fatalf("typed message = %+v, want non-empty type", tm) + } +} + +func TestAddRuleConfigRejectsGarbage(t *testing.T) { + if _, err := addRuleConfig(`{not json`); err == nil { + t.Fatal("expected error for malformed rule JSON") + } +} From f3e574c0a9a00919e7b2f20151de646567eebeda Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:45:49 +0330 Subject: [PATCH 11/20] feat(xray): implement RoutingBackend on *Xray --- backend/xray/routing.go | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 backend/xray/routing.go diff --git a/backend/xray/routing.go b/backend/xray/routing.go new file mode 100644 index 0000000..7bf58c2 --- /dev/null +++ b/backend/xray/routing.go @@ -0,0 +1,82 @@ +package xray + +import ( + "context" + "errors" + + "github.com/pasarguard/node/backend" + "github.com/pasarguard/node/common" +) + +// var _ asserts at compile time that *Xray satisfies backend.RoutingBackend. +var _ backend.RoutingBackend = (*Xray)(nil) + +// handler is the subset of *api.XrayHandler that routing delegates to. Using a +// local interface keeps this file self-documenting (*api.XrayHandler satisfies it). +type handler interface { + ListRoutingRules(context.Context) (*common.RoutingRulesResponse, error) + GetBalancerInfo(context.Context, string) (*common.BalancerInfoResponse, error) + TestRoute(context.Context, *common.TestRouteRequest) (*common.RouteResult, error) + AddRoutingRule(context.Context, string, bool) error + RemoveRoutingRule(context.Context, string) error + OverrideBalancerTarget(context.Context, string, string) error +} + +func (x *Xray) routingHandler() (handler, error) { + x.mu.RLock() + h := x.handler + started := x.core != nil && x.core.Started() + x.mu.RUnlock() + if !started || h == nil { + return nil, errors.New("xray not started") + } + return h, nil +} + +func (x *Xray) ListRoutingRules(ctx context.Context) (*common.RoutingRulesResponse, error) { + h, err := x.routingHandler() + if err != nil { + return nil, err + } + return h.ListRoutingRules(ctx) +} + +func (x *Xray) GetBalancerInfo(ctx context.Context, tag string) (*common.BalancerInfoResponse, error) { + h, err := x.routingHandler() + if err != nil { + return nil, err + } + return h.GetBalancerInfo(ctx, tag) +} + +func (x *Xray) TestRoute(ctx context.Context, req *common.TestRouteRequest) (*common.RouteResult, error) { + h, err := x.routingHandler() + if err != nil { + return nil, err + } + return h.TestRoute(ctx, req) +} + +func (x *Xray) AddRoutingRule(ctx context.Context, ruleJSON string, shouldAppend bool) error { + h, err := x.routingHandler() + if err != nil { + return err + } + return h.AddRoutingRule(ctx, ruleJSON, shouldAppend) +} + +func (x *Xray) RemoveRoutingRule(ctx context.Context, ruleTag string) error { + h, err := x.routingHandler() + if err != nil { + return err + } + return h.RemoveRoutingRule(ctx, ruleTag) +} + +func (x *Xray) OverrideBalancerTarget(ctx context.Context, balancerTag, target string) error { + h, err := x.routingHandler() + if err != nil { + return err + } + return h.OverrideBalancerTarget(ctx, balancerTag, target) +} From 1188684748e8244dadbbaf21fb14db150dc69cc2 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 22:47:58 +0330 Subject: [PATCH 12/20] feat(controller): add RoutingService RPC handlers --- controller/rpc/routing.go | 94 ++++++++++++++++++++++++++++++++++ controller/rpc/routing_test.go | 28 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 controller/rpc/routing.go create mode 100644 controller/rpc/routing_test.go diff --git a/controller/rpc/routing.go b/controller/rpc/routing.go new file mode 100644 index 0000000..eb760b8 --- /dev/null +++ b/controller/rpc/routing.go @@ -0,0 +1,94 @@ +package rpc + +import ( + "context" + + "github.com/pasarguard/node/backend" + "github.com/pasarguard/node/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// asRoutingBackend adapts a backend to RoutingBackend, returning Unimplemented +// for backends (e.g. WireGuard) that do not support xray routing. Kept as a free +// function so it is unit-testable without a live controller/backend. +func asRoutingBackend(b backend.Backend) (backend.RoutingBackend, error) { + rb, ok := b.(backend.RoutingBackend) + if !ok { + return nil, status.Errorf(codes.Unimplemented, "routing is only supported by the xray backend") + } + return rb, nil +} + +func (s *Service) routingBackend() (backend.RoutingBackend, error) { + b, err := s.backend() + if err != nil { + return nil, err + } + return asRoutingBackend(b) +} + +func (s *Service) ListRoutingRules(ctx context.Context, _ *common.Empty) (*common.RoutingRulesResponse, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + resp, err := rb.ListRoutingRules(ctx) + if err != nil { + return nil, common.InterceptNotFound(err) + } + return resp, nil +} + +func (s *Service) GetBalancerInfo(ctx context.Context, request *common.BalancerInfoRequest) (*common.BalancerInfoResponse, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + resp, err := rb.GetBalancerInfo(ctx, request.GetTag()) + if err != nil { + return nil, common.InterceptNotFound(err) + } + return resp, nil +} + +func (s *Service) TestRoute(ctx context.Context, request *common.TestRouteRequest) (*common.RouteResult, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + return rb.TestRoute(ctx, request) +} + +func (s *Service) AddRoutingRule(ctx context.Context, request *common.AddRoutingRuleRequest) (*common.Empty, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + if err := rb.AddRoutingRule(ctx, request.GetRule(), request.GetShouldAppend()); err != nil { + return nil, err + } + return &common.Empty{}, nil +} + +func (s *Service) RemoveRoutingRule(ctx context.Context, request *common.RemoveRoutingRuleRequest) (*common.Empty, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + if err := rb.RemoveRoutingRule(ctx, request.GetRuleTag()); err != nil { + return nil, common.InterceptNotFound(err) + } + return &common.Empty{}, nil +} + +func (s *Service) OverrideBalancerTarget(ctx context.Context, request *common.OverrideBalancerTargetRequest) (*common.Empty, error) { + rb, err := s.routingBackend() + if err != nil { + return nil, err + } + if err := rb.OverrideBalancerTarget(ctx, request.GetBalancerTag(), request.GetTarget()); err != nil { + return nil, common.InterceptNotFound(err) + } + return &common.Empty{}, nil +} diff --git a/controller/rpc/routing_test.go b/controller/rpc/routing_test.go new file mode 100644 index 0000000..9cd43a7 --- /dev/null +++ b/controller/rpc/routing_test.go @@ -0,0 +1,28 @@ +package rpc + +import ( + "testing" + + "github.com/pasarguard/node/backend" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// nonRoutingBackend satisfies backend.Backend (methods promoted from the nil +// embedded interface — never called here) but NOT backend.RoutingBackend. +type nonRoutingBackend struct{ backend.Backend } + +// routingFake satisfies both interfaces. +type routingFake struct { + backend.Backend + backend.RoutingBackend +} + +func TestAsRoutingBackend(t *testing.T) { + if _, err := asRoutingBackend(nonRoutingBackend{}); status.Code(err) != codes.Unimplemented { + t.Fatalf("non-routing backend: code = %v, want Unimplemented", status.Code(err)) + } + if _, err := asRoutingBackend(routingFake{}); err != nil { + t.Fatalf("routing backend: unexpected err %v", err) + } +} From 1f28d9433fc6c312b720ebdf30f9d4e5d8ab6f36 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 23:02:55 +0330 Subject: [PATCH 13/20] fix(routing): gate routing RPCs and return InvalidArgument for bad rules CP3 spec-compliance review fixes: - Register the 6 RoutingService RPCs in backendMethods so they get the same controlling-client + started gating as their stats.go neighbors. - Return codes.InvalidArgument for malformed AddRoutingRule JSON (was Unknown). --- backend/xray/api/routing.go | 8 ++++---- backend/xray/api/routing_test.go | 8 +++++++- controller/rpc/middleware.go | 6 ++++++ controller/rpc/middleware_test.go | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 controller/rpc/middleware_test.go diff --git a/backend/xray/api/routing.go b/backend/xray/api/routing.go index 700c005..e74c86e 100644 --- a/backend/xray/api/routing.go +++ b/backend/xray/api/routing.go @@ -3,8 +3,6 @@ package api import ( "context" "encoding/json" - "errors" - "fmt" "net" "strings" @@ -13,6 +11,8 @@ import ( xnet "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/infra/conf" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func toCommonRoutingRules(resp *routingCommand.ListRuleResponse) *common.RoutingRulesResponse { @@ -87,10 +87,10 @@ func addRuleConfig(ruleJSON string) (*serial.TypedMessage, error) { rc := &conf.RouterConfig{RuleList: []json.RawMessage{json.RawMessage(ruleJSON)}} built, err := rc.Build() if err != nil { - return nil, fmt.Errorf("parse routing rule: %w", err) + return nil, status.Errorf(codes.InvalidArgument, "parse routing rule: %v", err) } if len(built.Rule) == 0 { - return nil, errors.New("no routing rule parsed from JSON") + return nil, status.Error(codes.InvalidArgument, "no routing rule parsed from JSON") } return serial.ToTypedMessage(built.Rule[0]), nil } diff --git a/backend/xray/api/routing_test.go b/backend/xray/api/routing_test.go index 690a5cd..a8b0fcd 100644 --- a/backend/xray/api/routing_test.go +++ b/backend/xray/api/routing_test.go @@ -6,6 +6,8 @@ import ( "github.com/pasarguard/node/common" routingCommand "github.com/xtls/xray-core/app/router/command" xnet "github.com/xtls/xray-core/common/net" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func TestToCommonRoutingRules(t *testing.T) { @@ -91,7 +93,11 @@ func TestAddRuleConfigParsesJSON(t *testing.T) { } func TestAddRuleConfigRejectsGarbage(t *testing.T) { - if _, err := addRuleConfig(`{not json`); err == nil { + _, err := addRuleConfig(`{not json`) + if err == nil { t.Fatal("expected error for malformed rule JSON") } + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", status.Code(err)) + } } diff --git a/controller/rpc/middleware.go b/controller/rpc/middleware.go index 8536a60..4a90bb8 100644 --- a/controller/rpc/middleware.go +++ b/controller/rpc/middleware.go @@ -251,6 +251,12 @@ var backendMethods = map[string]bool{ "/service.NodeService/SyncUsers": true, "/service.NodeService/SyncUsersChunked": true, "/service.NodeService/GetLogs": true, + "/service.NodeService/ListRoutingRules": true, + "/service.NodeService/GetBalancerInfo": true, + "/service.NodeService/TestRoute": true, + "/service.NodeService/AddRoutingRule": true, + "/service.NodeService/RemoveRoutingRule": true, + "/service.NodeService/OverrideBalancerTarget": true, } func ConditionalMiddleware(s *Service) grpc.UnaryServerInterceptor { diff --git a/controller/rpc/middleware_test.go b/controller/rpc/middleware_test.go new file mode 100644 index 0000000..a0c756e --- /dev/null +++ b/controller/rpc/middleware_test.go @@ -0,0 +1,19 @@ +package rpc + +import "testing" + +func TestRoutingMethodsAreBackendGated(t *testing.T) { + methods := []string{ + "/service.NodeService/ListRoutingRules", + "/service.NodeService/GetBalancerInfo", + "/service.NodeService/TestRoute", + "/service.NodeService/AddRoutingRule", + "/service.NodeService/RemoveRoutingRule", + "/service.NodeService/OverrideBalancerTarget", + } + for _, m := range methods { + if !backendMethods[m] { + t.Errorf("%s missing from backendMethods (skips controlling-client/started gating)", m) + } + } +} From 64b41a906be31f2d7e69ce76d16412f261c2adf3 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 23:17:53 +0330 Subject: [PATCH 14/20] test(xray): cover routing not-started guard and fix test formatting --- backend/xray/api/routing_test.go | 16 +++++++-------- backend/xray/routing_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 backend/xray/routing_test.go diff --git a/backend/xray/api/routing_test.go b/backend/xray/api/routing_test.go index a8b0fcd..405965e 100644 --- a/backend/xray/api/routing_test.go +++ b/backend/xray/api/routing_test.go @@ -44,14 +44,14 @@ func TestToCommonBalancerInfo(t *testing.T) { func TestBuildTestRouteRequest(t *testing.T) { req := buildTestRouteRequest(&common.TestRouteRequest{ - InboundTag: "in", - Network: "udp", - TargetIp: "1.1.1.1", - TargetDomain: "example.com", - TargetPort: 443, - Protocol: "tls", - User: "u", - Attributes: map[string]string{"k": "v"}, + InboundTag: "in", + Network: "udp", + TargetIp: "1.1.1.1", + TargetDomain: "example.com", + TargetPort: 443, + Protocol: "tls", + User: "u", + Attributes: map[string]string{"k": "v"}, PublishResult: true, }) if req.RoutingContext.Network != xnet.Network_UDP { diff --git a/backend/xray/routing_test.go b/backend/xray/routing_test.go new file mode 100644 index 0000000..f429b5e --- /dev/null +++ b/backend/xray/routing_test.go @@ -0,0 +1,35 @@ +package xray + +import ( + "context" + "testing" + + "github.com/pasarguard/node/common" +) + +// TestRoutingMethodsErrorWhenNotStarted verifies every RoutingBackend method on +// *Xray goes through the routingHandler() guard and errors (rather than panics) +// when the core is not started / the handler is nil. +func TestRoutingMethodsErrorWhenNotStarted(t *testing.T) { + x := &Xray{} // core nil, handler nil => not started + ctx := context.Background() + + if _, err := x.ListRoutingRules(ctx); err == nil { + t.Fatal("ListRoutingRules: expected error when not started") + } + if _, err := x.GetBalancerInfo(ctx, "b"); err == nil { + t.Fatal("GetBalancerInfo: expected error when not started") + } + if _, err := x.TestRoute(ctx, &common.TestRouteRequest{}); err == nil { + t.Fatal("TestRoute: expected error when not started") + } + if err := x.AddRoutingRule(ctx, "{}", false); err == nil { + t.Fatal("AddRoutingRule: expected error when not started") + } + if err := x.RemoveRoutingRule(ctx, "r"); err == nil { + t.Fatal("RemoveRoutingRule: expected error when not started") + } + if err := x.OverrideBalancerTarget(ctx, "b", "t"); err == nil { + t.Fatal("OverrideBalancerTarget: expected error when not started") + } +} From 80e29275254be5d22be5ebaf66e2753feaac2df1 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Tue, 30 Jun 2026 10:24:52 +0330 Subject: [PATCH 15/20] fix(xray/api): send full router Config to AddRule xray's Router.AddRule unwraps the TypedMessage and requires a *router.Config; a bare *router.RoutingRule is rejected with "config type error". Send the built config (carrying the single parsed rule) instead, and assert the *router.Config instance type in the unit test. Fixes AddRoutingRule on both gRPC and REST. --- backend/xray/api/routing.go | 6 ++++-- backend/xray/api/routing_test.go | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/xray/api/routing.go b/backend/xray/api/routing.go index e74c86e..cdab18b 100644 --- a/backend/xray/api/routing.go +++ b/backend/xray/api/routing.go @@ -82,7 +82,9 @@ func toCommonRouteResult(rc *routingCommand.RoutingContext) *common.RouteResult // addRuleConfig parses one routing-rule JSON (same shape as routing.rules[]) into // the TypedMessage that RoutingService.AddRule expects, using only exported -// xray-core API. +// xray-core API. xray's Router.AddRule unwraps the message and requires a +// *router.Config (it rejects a bare *router.RoutingRule with "config type +// error"), so the whole built config — carrying the single parsed rule — is sent. func addRuleConfig(ruleJSON string) (*serial.TypedMessage, error) { rc := &conf.RouterConfig{RuleList: []json.RawMessage{json.RawMessage(ruleJSON)}} built, err := rc.Build() @@ -92,7 +94,7 @@ func addRuleConfig(ruleJSON string) (*serial.TypedMessage, error) { if len(built.Rule) == 0 { return nil, status.Error(codes.InvalidArgument, "no routing rule parsed from JSON") } - return serial.ToTypedMessage(built.Rule[0]), nil + return serial.ToTypedMessage(built), nil } func (x *XrayHandler) ListRoutingRules(ctx context.Context) (*common.RoutingRulesResponse, error) { diff --git a/backend/xray/api/routing_test.go b/backend/xray/api/routing_test.go index 405965e..9b23e97 100644 --- a/backend/xray/api/routing_test.go +++ b/backend/xray/api/routing_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/pasarguard/node/common" + "github.com/xtls/xray-core/app/router" routingCommand "github.com/xtls/xray-core/app/router/command" xnet "github.com/xtls/xray-core/common/net" "google.golang.org/grpc/codes" @@ -87,8 +88,14 @@ func TestAddRuleConfigParsesJSON(t *testing.T) { if err != nil { t.Fatalf("addRuleConfig error: %v", err) } - if tm == nil || tm.Type == "" { - t.Fatalf("typed message = %+v, want non-empty type", tm) + // xray's Router.AddRule unwraps the message and requires a *router.Config; + // a bare *router.RoutingRule is rejected with "config type error". + inst, err := tm.GetInstance() + if err != nil { + t.Fatalf("GetInstance error: %v", err) + } + if _, ok := inst.(*router.Config); !ok { + t.Fatalf("typed message instance = %T, want *router.Config", inst) } } From 082829ebdeabd5a14f8c439c7cb666c37521e2ac Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Tue, 30 Jun 2026 10:24:59 +0330 Subject: [PATCH 16/20] feat(rest): expose RoutingService over REST Add REST handlers for the 6 routing operations under /routing, registered in the gated private group (controlling-client + started middleware), reusing the RoutingBackend capability gate and the gRPC-code-to-HTTP error mapping used by the stats handlers. Enable RoutingService in the test config and add unit (capability gate) + integration (list/add/remove, malformed->400, route wiring) coverage. --- backend/xray/config.json | 3 + controller/rest/rest_test.go | 69 ++++++++++++++ controller/rest/routing.go | 157 ++++++++++++++++++++++++++++++++ controller/rest/routing_test.go | 28 ++++++ controller/rest/service.go | 10 ++ 5 files changed, 267 insertions(+) create mode 100644 controller/rest/routing.go create mode 100644 controller/rest/routing_test.go diff --git a/backend/xray/config.json b/backend/xray/config.json index 44c6281..ad04986 100644 --- a/backend/xray/config.json +++ b/backend/xray/config.json @@ -2,6 +2,9 @@ "log": { "loglevel": "warning" }, + "api": { + "services": ["RoutingService"] + }, "inbounds": [ { "tag": "Shadowsocks TCP", diff --git a/controller/rest/rest_test.go b/controller/rest/rest_test.go index 3606fab..a1903cf 100644 --- a/controller/rest/rest_test.go +++ b/controller/rest/rest_test.go @@ -11,6 +11,7 @@ import ( "log" "net/http" "os" + "strings" "testing" "time" @@ -386,6 +387,74 @@ func TestREST_GetSystemStats(t *testing.T) { systemStats.MemTotal, systemStats.MemUsed, systemStats.CpuCores, systemStats.CpuUsage, systemStats.IncomingBandwidthSpeed, systemStats.OutgoingBandwidthSpeed) } +func TestREST_Routing(t *testing.T) { + doRouting := func(method, endpoint string, reqMsg proto.Message) (int, []byte) { + var body []byte + if reqMsg != nil { + var err error + if body, err = proto.Marshal(reqMsg); err != nil { + t.Fatalf("marshal request: %v", err) + } + } + req, err := http.NewRequest(method, sharedTestCtx.url+endpoint, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("x-api-key", apiKey.String()) + req.Header.Set("Content-Type", "application/x-protobuf") + resp, err := sharedTestCtx.client.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + return resp.StatusCode, respBody + } + + // assertRegistered confirms a route exists (handler ran) rather than chi's + // default not-found, regardless of whether the routing op itself succeeds. + assertRegistered := func(method, endpoint string, reqMsg proto.Message) { + code, body := doRouting(method, endpoint, reqMsg) + if code == http.StatusNotFound && strings.Contains(string(body), "404 page not found") { + t.Fatalf("%s %s not registered (chi 404)", method, endpoint) + } + } + + // List rules (RoutingService enabled in the test config) -> 200 + decodes. + code, body := doRouting("GET", "/routing/rules", &common.Empty{}) + if code != http.StatusOK { + t.Fatalf("ListRoutingRules: status = %d, body = %s", code, body) + } + var rules common.RoutingRulesResponse + if err := proto.Unmarshal(body, &rules); err != nil { + t.Fatalf("decode RoutingRulesResponse: %v", err) + } + + // Add a valid rule -> 200, then remove it by tag -> 200. + code, body = doRouting("PUT", "/routing/rules", &common.AddRoutingRuleRequest{ + Rule: `{"type":"field","outboundTag":"direct","domain":["rest-routing-test.example.com"],"ruleTag":"rest-routing-test"}`, + ShouldAppend: true, + }) + if code != http.StatusOK { + t.Fatalf("AddRoutingRule(valid): status = %d, body = %s", code, body) + } + code, body = doRouting("DELETE", "/routing/rules", &common.RemoveRoutingRuleRequest{RuleTag: "rest-routing-test"}) + if code != http.StatusOK { + t.Fatalf("RemoveRoutingRule: status = %d, body = %s", code, body) + } + + // Malformed rule JSON -> 400 (InvalidArgument mapped to HTTP). + code, body = doRouting("PUT", "/routing/rules", &common.AddRoutingRuleRequest{Rule: "{not json"}) + if code != http.StatusBadRequest { + t.Fatalf("AddRoutingRule(malformed): status = %d, want 400, body = %s", code, body) + } + + // Remaining routes: confirm they are wired (handler runs, not chi 404). + assertRegistered("GET", "/routing/balancer", &common.BalancerInfoRequest{Tag: "none"}) + assertRegistered("GET", "/routing/test", &common.TestRouteRequest{Network: "tcp", TargetDomain: "example.com"}) + assertRegistered("PUT", "/routing/balancer/override", &common.OverrideBalancerTargetRequest{BalancerTag: "none", Target: "none"}) +} + func TestREST_StopBackend(t *testing.T) { user := &common.User{} if err := sharedTestCtx.createAuthenticatedRequest("PUT", "/stop", user, &common.Empty{}); err != nil { diff --git a/controller/rest/routing.go b/controller/rest/routing.go new file mode 100644 index 0000000..6fb2da0 --- /dev/null +++ b/controller/rest/routing.go @@ -0,0 +1,157 @@ +package rest + +import ( + "net/http" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/pasarguard/node/backend" + "github.com/pasarguard/node/common" +) + +// asRoutingBackend adapts a backend to RoutingBackend, returning Unimplemented +// for backends (e.g. WireGuard) that do not support xray routing. Kept as a free +// function so the capability gate is unit-testable without a live server. +func asRoutingBackend(b backend.Backend) (backend.RoutingBackend, error) { + rb, ok := b.(backend.RoutingBackend) + if !ok { + return nil, status.Error(codes.Unimplemented, "routing is only supported by the xray backend") + } + return rb, nil +} + +func (s *Service) routingBackend() (backend.RoutingBackend, error) { + return asRoutingBackend(s.Backend()) +} + +// writeRoutingError maps a backend error to the matching HTTP status, mirroring +// the stats.go handlers (InterceptNotFound + gRPC-code-to-HTTP). +func writeRoutingError(w http.ResponseWriter, err error) { + err = common.InterceptNotFound(err) + st, _ := status.FromError(err) + http.Error(w, err.Error(), common.GrpcCodeToHTTP(st.Code())) +} + +func (s *Service) ListRoutingRules(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + resp, err := rb.ListRoutingRules(r.Context()) + if err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, resp) +} + +func (s *Service) GetBalancerInfo(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + var request common.BalancerInfoRequest + if err := common.ReadProtoBody(r.Body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := rb.GetBalancerInfo(r.Context(), request.GetTag()) + if err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, resp) +} + +func (s *Service) TestRoute(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + var request common.TestRouteRequest + if err := common.ReadProtoBody(r.Body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := rb.TestRoute(r.Context(), &request) + if err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, resp) +} + +func (s *Service) AddRoutingRule(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + var request common.AddRoutingRuleRequest + if err := common.ReadProtoBody(r.Body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := rb.AddRoutingRule(r.Context(), request.GetRule(), request.GetShouldAppend()); err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, &common.Empty{}) +} + +func (s *Service) RemoveRoutingRule(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + var request common.RemoveRoutingRuleRequest + if err := common.ReadProtoBody(r.Body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := rb.RemoveRoutingRule(r.Context(), request.GetRuleTag()); err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, &common.Empty{}) +} + +func (s *Service) OverrideBalancerTarget(w http.ResponseWriter, r *http.Request) { + rb, err := s.routingBackend() + if err != nil { + writeRoutingError(w, err) + return + } + + var request common.OverrideBalancerTargetRequest + if err := common.ReadProtoBody(r.Body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := rb.OverrideBalancerTarget(r.Context(), request.GetBalancerTag(), request.GetTarget()); err != nil { + writeRoutingError(w, err) + return + } + + common.SendProtoResponse(w, &common.Empty{}) +} diff --git a/controller/rest/routing_test.go b/controller/rest/routing_test.go new file mode 100644 index 0000000..0c5b77e --- /dev/null +++ b/controller/rest/routing_test.go @@ -0,0 +1,28 @@ +package rest + +import ( + "testing" + + "github.com/pasarguard/node/backend" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// nonRoutingBackend satisfies backend.Backend (methods promoted from the nil +// embedded interface — never called here) but NOT backend.RoutingBackend. +type nonRoutingBackend struct{ backend.Backend } + +// routingFake satisfies both interfaces. +type routingFake struct { + backend.Backend + backend.RoutingBackend +} + +func TestAsRoutingBackend(t *testing.T) { + if _, err := asRoutingBackend(nonRoutingBackend{}); status.Code(err) != codes.Unimplemented { + t.Fatalf("non-routing backend: code = %v, want Unimplemented", status.Code(err)) + } + if _, err := asRoutingBackend(routingFake{}); err != nil { + t.Fatalf("routing backend: unexpected err %v", err) + } +} diff --git a/controller/rest/service.go b/controller/rest/service.go index 1b95721..7267a41 100644 --- a/controller/rest/service.go +++ b/controller/rest/service.go @@ -52,6 +52,16 @@ func (s *Service) setRouter() { private.Put("/user/sync", s.SyncUser) private.Put("/users/sync", s.SyncUsers) private.Put("/users/sync/chunked", s.SyncUsersChunked) + + // routing api (xray-only; non-xray backends get codes.Unimplemented -> 501) + private.Route("/routing", func(routingGroup chi.Router) { + routingGroup.Get("/rules", s.ListRoutingRules) + routingGroup.Put("/rules", s.AddRoutingRule) + routingGroup.Delete("/rules", s.RemoveRoutingRule) + routingGroup.Get("/balancer", s.GetBalancerInfo) + routingGroup.Put("/balancer/override", s.OverrideBalancerTarget) + routingGroup.Get("/test", s.TestRoute) + }) }) s.Router = router From 9ec104ebfde5b7e48f57d7ab824ee06931bd1b8a Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Tue, 30 Jun 2026 10:50:09 +0330 Subject: [PATCH 17/20] fix(routing): default AddRoutingRule to append (safe should_reset) The proto bool defaulted to false, and xray treats AddRule(shouldAppend=false) as "reset all rules + balancers". Renamed the field to should_reset so the wire default (false) is the non-destructive append; handlers pass !should_reset to the backend. A direct caller that omits the field no longer wipes the routing table. The REST integration test now asserts a default add appends (both rules survive). --- common/service.pb.go | 19 ++++++++++-------- common/service.proto | 5 ++++- controller/rest/rest_test.go | 38 +++++++++++++++++++++++++++--------- controller/rest/routing.go | 2 +- controller/rpc/routing.go | 2 +- 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/common/service.pb.go b/common/service.pb.go index a47162b..a4b9a8d 100644 --- a/common/service.pb.go +++ b/common/service.pb.go @@ -1931,9 +1931,12 @@ func (x *RouteResult) GetTargetDomain() string { } type AddRoutingRuleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Rule string `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` // JSON for one xray routing rule (same shape as routing.rules[]) - ShouldAppend bool `protobuf:"varint,2,opt,name=should_append,json=shouldAppend,proto3" json:"should_append,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Rule string `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"` // JSON for one xray routing rule (same shape as routing.rules[]) + // false (default) appends the rule, keeping existing rules; true resets the + // router (clears all rules + balancers) before adding. Default is the safe, + // non-destructive behavior. + ShouldReset bool `protobuf:"varint,2,opt,name=should_reset,json=shouldReset,proto3" json:"should_reset,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1975,9 +1978,9 @@ func (x *AddRoutingRuleRequest) GetRule() string { return "" } -func (x *AddRoutingRuleRequest) GetShouldAppend() bool { +func (x *AddRoutingRuleRequest) GetShouldReset() bool { if x != nil { - return x.ShouldAppend + return x.ShouldReset } return false } @@ -2219,10 +2222,10 @@ const file_common_service_proto_rawDesc = "" + "\vinbound_tag\x18\x03 \x01(\tR\n" + "inboundTag\x12\x18\n" + "\anetwork\x18\x04 \x01(\tR\anetwork\x12#\n" + - "\rtarget_domain\x18\x05 \x01(\tR\ftargetDomain\"P\n" + + "\rtarget_domain\x18\x05 \x01(\tR\ftargetDomain\"N\n" + "\x15AddRoutingRuleRequest\x12\x12\n" + - "\x04rule\x18\x01 \x01(\tR\x04rule\x12#\n" + - "\rshould_append\x18\x02 \x01(\bR\fshouldAppend\"5\n" + + "\x04rule\x18\x01 \x01(\tR\x04rule\x12!\n" + + "\fshould_reset\x18\x02 \x01(\bR\vshouldReset\"5\n" + "\x18RemoveRoutingRuleRequest\x12\x19\n" + "\brule_tag\x18\x01 \x01(\tR\aruleTag\"Z\n" + "\x1dOverrideBalancerTargetRequest\x12!\n" + diff --git a/common/service.proto b/common/service.proto index 5c0f398..486396e 100644 --- a/common/service.proto +++ b/common/service.proto @@ -197,7 +197,10 @@ message RouteResult { message AddRoutingRuleRequest { string rule = 1; // JSON for one xray routing rule (same shape as routing.rules[]) - bool should_append = 2; + // false (default) appends the rule, keeping existing rules; true resets the + // router (clears all rules + balancers) before adding. Default is the safe, + // non-destructive behavior. + bool should_reset = 2; } message RemoveRoutingRuleRequest { string rule_tag = 1; } message OverrideBalancerTargetRequest { diff --git a/controller/rest/rest_test.go b/controller/rest/rest_test.go index a1903cf..5a771c6 100644 --- a/controller/rest/rest_test.go +++ b/controller/rest/rest_test.go @@ -430,17 +430,37 @@ func TestREST_Routing(t *testing.T) { t.Fatalf("decode RoutingRulesResponse: %v", err) } - // Add a valid rule -> 200, then remove it by tag -> 200. - code, body = doRouting("PUT", "/routing/rules", &common.AddRoutingRuleRequest{ - Rule: `{"type":"field","outboundTag":"direct","domain":["rest-routing-test.example.com"],"ruleTag":"rest-routing-test"}`, - ShouldAppend: true, - }) - if code != http.StatusOK { - t.Fatalf("AddRoutingRule(valid): status = %d, body = %s", code, body) + // Two adds with the default (should_reset unset) must APPEND: the second add + // must keep the first rule rather than resetting the router. This guards the + // safe, non-destructive wire default. + addTags := []string{"rest-routing-test-a", "rest-routing-test-b"} + for _, tag := range addTags { + code, body = doRouting("PUT", "/routing/rules", &common.AddRoutingRuleRequest{ + Rule: `{"type":"field","outboundTag":"direct","domain":["` + tag + `.example.com"],"ruleTag":"` + tag + `"}`, + }) + if code != http.StatusOK { + t.Fatalf("AddRoutingRule(%s): status = %d, body = %s", tag, code, body) + } } - code, body = doRouting("DELETE", "/routing/rules", &common.RemoveRoutingRuleRequest{RuleTag: "rest-routing-test"}) + code, body = doRouting("GET", "/routing/rules", &common.Empty{}) if code != http.StatusOK { - t.Fatalf("RemoveRoutingRule: status = %d, body = %s", code, body) + t.Fatalf("ListRoutingRules(after appends): status = %d, body = %s", code, body) + } + var listed common.RoutingRulesResponse + if err := proto.Unmarshal(body, &listed); err != nil { + t.Fatalf("decode RoutingRulesResponse: %v", err) + } + tags := make(map[string]bool) + for _, ru := range listed.GetRules() { + tags[ru.GetRuleTag()] = true + } + if !tags["rest-routing-test-a"] || !tags["rest-routing-test-b"] { + t.Fatalf("default add did not append (both rules should survive); got tags = %v", tags) + } + for _, tag := range addTags { + if code, body = doRouting("DELETE", "/routing/rules", &common.RemoveRoutingRuleRequest{RuleTag: tag}); code != http.StatusOK { + t.Fatalf("RemoveRoutingRule(%s): status = %d, body = %s", tag, code, body) + } } // Malformed rule JSON -> 400 (InvalidArgument mapped to HTTP). diff --git a/controller/rest/routing.go b/controller/rest/routing.go index 6fb2da0..f7a2766 100644 --- a/controller/rest/routing.go +++ b/controller/rest/routing.go @@ -106,7 +106,7 @@ func (s *Service) AddRoutingRule(w http.ResponseWriter, r *http.Request) { return } - if err := rb.AddRoutingRule(r.Context(), request.GetRule(), request.GetShouldAppend()); err != nil { + if err := rb.AddRoutingRule(r.Context(), request.GetRule(), !request.GetShouldReset()); err != nil { writeRoutingError(w, err) return } diff --git a/controller/rpc/routing.go b/controller/rpc/routing.go index eb760b8..a5b7f33 100644 --- a/controller/rpc/routing.go +++ b/controller/rpc/routing.go @@ -65,7 +65,7 @@ func (s *Service) AddRoutingRule(ctx context.Context, request *common.AddRouting if err != nil { return nil, err } - if err := rb.AddRoutingRule(ctx, request.GetRule(), request.GetShouldAppend()); err != nil { + if err := rb.AddRoutingRule(ctx, request.GetRule(), !request.GetShouldReset()); err != nil { return nil, err } return &common.Empty{}, nil From df7817cc320d810081a2d450e5eb8a14c6338156 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Fri, 10 Jul 2026 22:18:12 +0330 Subject: [PATCH 18/20] fix(xray/api): keep gRPC clients set after Close to avoid shutdown race panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XrayHandler.Close nilled the service client fields, so an RPC racing Shutdown (guard passed, then Close ran) dereferenced a nil client and panicked — and the gRPC server chain has no recovery interceptor, so that took the process down. Closing the grpc.ClientConn is enough: in-flight and later calls now fail cleanly with a connection-closing error. Also covers the same latent race in the stats/handler client paths. Addresses CodeRabbit review on #60 (TOCTOU in routingHandler); fixed at the client-lifetime level instead of holding x.mu across delegated network calls, which would block Shutdown behind in-flight RPCs. --- backend/xray/api/base.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/xray/api/base.go b/backend/xray/api/base.go index 8fce70a..88036f0 100644 --- a/backend/xray/api/base.go +++ b/backend/xray/api/base.go @@ -64,10 +64,10 @@ func NewXrayAPI(apiPort int) (*XrayHandler, error) { } func (x *XrayHandler) Close() { + // The service clients are intentionally left set: a call racing Close (e.g. + // an RPC in flight while Shutdown runs) then fails cleanly on the closed + // grpc.ClientConn instead of panicking on a nil client dereference. if x.GrpcClient != nil { _ = x.GrpcClient.Close() } - x.StatsServiceClient = nil - x.RoutingServiceClient = nil - x.HandlerServiceClient = nil } From bb9a46120499cb8b75ef81a18d111f5e49a96209 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Fri, 10 Jul 2026 22:18:12 +0330 Subject: [PATCH 19/20] fix(rest): use POST for routing endpoints that carry request bodies GetBalancerInfo and TestRoute parse protobuf request bodies but were registered as GET. RFC 9110 discourages GET bodies, fetch()-style clients cannot send them, and intermediaries may drop them. Switch both to POST while the API is still unreleased; handlers are unchanged. Addresses CodeRabbit review on #60. The pre-existing /stats GET-with-body endpoints are left as-is (released surface, out of scope here). --- controller/rest/rest_test.go | 5 +++-- controller/rest/service.go | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/controller/rest/rest_test.go b/controller/rest/rest_test.go index 5a771c6..5c1c456 100644 --- a/controller/rest/rest_test.go +++ b/controller/rest/rest_test.go @@ -470,8 +470,9 @@ func TestREST_Routing(t *testing.T) { } // Remaining routes: confirm they are wired (handler runs, not chi 404). - assertRegistered("GET", "/routing/balancer", &common.BalancerInfoRequest{Tag: "none"}) - assertRegistered("GET", "/routing/test", &common.TestRouteRequest{Network: "tcp", TargetDomain: "example.com"}) + // Balancer info and test-route are POST because they carry request bodies. + assertRegistered("POST", "/routing/balancer", &common.BalancerInfoRequest{Tag: "none"}) + assertRegistered("POST", "/routing/test", &common.TestRouteRequest{Network: "tcp", TargetDomain: "example.com"}) assertRegistered("PUT", "/routing/balancer/override", &common.OverrideBalancerTargetRequest{BalancerTag: "none", Target: "none"}) } diff --git a/controller/rest/service.go b/controller/rest/service.go index 7267a41..6be1eea 100644 --- a/controller/rest/service.go +++ b/controller/rest/service.go @@ -53,14 +53,17 @@ func (s *Service) setRouter() { private.Put("/users/sync", s.SyncUsers) private.Put("/users/sync/chunked", s.SyncUsersChunked) - // routing api (xray-only; non-xray backends get codes.Unimplemented -> 501) + // routing api (xray-only; non-xray backends get codes.Unimplemented -> 501). + // Balancer info and test-route carry protobuf request bodies, so they use + // POST: GET-with-body breaks fetch()-style clients and may be dropped by + // intermediaries (RFC 9110 discourages it). private.Route("/routing", func(routingGroup chi.Router) { routingGroup.Get("/rules", s.ListRoutingRules) routingGroup.Put("/rules", s.AddRoutingRule) routingGroup.Delete("/rules", s.RemoveRoutingRule) - routingGroup.Get("/balancer", s.GetBalancerInfo) + routingGroup.Post("/balancer", s.GetBalancerInfo) routingGroup.Put("/balancer/override", s.OverrideBalancerTarget) - routingGroup.Get("/test", s.TestRoute) + routingGroup.Post("/test", s.TestRoute) }) }) From afaa792a1b472657226fe4ccef9824676d19f42c Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Fri, 10 Jul 2026 22:18:12 +0330 Subject: [PATCH 20/20] fix(rpc): intercept not-found errors in TestRoute like sibling handlers TestRoute was the only routing RPC not wrapping backend errors with common.InterceptNotFound, so an identical error could map to NotFound on REST (writeRoutingError applies it) but Unknown on gRPC. Behaviorally a no-op for today's xray errors, but keeps the two transports consistent. Addresses CodeRabbit review on #60. --- controller/rpc/routing.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/controller/rpc/routing.go b/controller/rpc/routing.go index a5b7f33..a8f335f 100644 --- a/controller/rpc/routing.go +++ b/controller/rpc/routing.go @@ -57,7 +57,11 @@ func (s *Service) TestRoute(ctx context.Context, request *common.TestRouteReques if err != nil { return nil, err } - return rb.TestRoute(ctx, request) + resp, err := rb.TestRoute(ctx, request) + if err != nil { + return nil, common.InterceptNotFound(err) + } + return resp, nil } func (s *Service) AddRoutingRule(ctx context.Context, request *common.AddRoutingRuleRequest) (*common.Empty, error) {