From 3e8551814a39062effbb398b7ecf9de634b89ffe Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 12:32:32 +0330 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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/