Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ xray-core/

# AI
.AI
graphify-out
superpowers/

certs/
generated/
Expand Down
69 changes: 68 additions & 1 deletion backend/xray/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,65 @@ 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: 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",
"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": {},
Expand Down Expand Up @@ -704,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{
Expand Down
105 changes: 105 additions & 0 deletions backend/xray/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package xray

import (
"slices"
"testing"

"github.com/xtls/xray-core/infra/conf"
)

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: "dedupe extras across case",
in: []string{"routingservice", "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)
}
})
}
}

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)
}
}

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)
}
}