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/ diff --git a/backend/xray/config.go b/backend/xray/config.go index 9ffa05e..3177e38 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -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": {}, @@ -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{ diff --git a/backend/xray/config_test.go b/backend/xray/config_test.go new file mode 100644 index 0000000..8d6b451 --- /dev/null +++ b/backend/xray/config_test.go @@ -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) + } +}