diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index 8b28eedfb..1e9ce2ad2 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -157,7 +157,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes } tag, _ := ob["tag"].(string) protocol, _ := ob["protocol"].(string) - if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" { + if equalsAnyFold(protocol, "blackhole", "freedom") || tag == "blocked" { return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil } diff --git a/internal/web/service/outbound/probe_protocol_case_test.go b/internal/web/service/outbound/probe_protocol_case_test.go index 4079d515f..8109df16f 100644 --- a/internal/web/service/outbound/probe_protocol_case_test.go +++ b/internal/web/service/outbound/probe_protocol_case_test.go @@ -110,6 +110,37 @@ func TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore(t *testing.T) { } } +func TestTestOutboundsRejectsUntestableIDsInAnyCase(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"canonical freedom", "freedom"}, + {"capitalised freedom", "Freedom"}, + {"upper freedom", "FREEDOM"}, + {"canonical blackhole", "blackhole"}, + {"capitalised blackhole", "Blackhole"}, + } + + const wantErr = "Outbound has no testable endpoint" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + batch := mustJSON(t, []any{map[string]any{"tag": "t1", "protocol": tt.protocol}}) + results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + r := results[0] + if r.Success { + t.Errorf("%q outbound = %+v, want a rejection", tt.protocol, r) + } + if r.Error != wantErr { + t.Errorf("%q error = %q, want %q", tt.protocol, r.Error, wantErr) + } + }) + } +} + func TestTestOutboundsTCPLaneReadsProtocolIDCaseInsensitively(t *testing.T) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/web/service/xray_setting_routing_sync.go b/internal/web/service/xray_setting_routing_sync.go index c230b6f49..29961d770 100644 --- a/internal/web/service/xray_setting_routing_sync.go +++ b/internal/web/service/xray_setting_routing_sync.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" ) var routingMatcherKeys = []string{ @@ -142,7 +143,7 @@ func replaceInboundTagInOutbounds(outbounds []any, oldTag, newTag string) bool { continue } proto, _ := out["protocol"].(string) - if proto != "loopback" { + if !strings.EqualFold(proto, "loopback") { continue } settings, ok := out["settings"].(map[string]any) @@ -167,7 +168,7 @@ func removeInboundTagFromOutbounds(outbounds []any, deletedTag string) bool { continue } proto, _ := out["protocol"].(string) - if proto != "loopback" { + if !strings.EqualFold(proto, "loopback") { continue } settings, ok := out["settings"].(map[string]any) diff --git a/internal/web/service/xray_setting_routing_sync_test.go b/internal/web/service/xray_setting_routing_sync_test.go index cf3e1b7ec..effe8c7eb 100644 --- a/internal/web/service/xray_setting_routing_sync_test.go +++ b/internal/web/service/xray_setting_routing_sync_test.go @@ -221,6 +221,73 @@ func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing } } +// The core lowercases a protocol id before resolving the handler, so "Loopback" +// is the loopback outbound whose inboundTag has to follow the inbound it names. +func TestReplaceInboundTagInOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) { + tests := []struct { + name string + protocol any + want bool + wantTag any + }{ + {"canonical loopback is rewritten", "loopback", true, "new-tag"}, + {"capitalised loopback is rewritten", "Loopback", true, "new-tag"}, + {"uppercase loopback is rewritten", "LOOPBACK", true, "new-tag"}, + {"another protocol keeps its tag", "vmess", false, "old-tag"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outbounds := []any{map[string]any{ + "protocol": tt.protocol, + "settings": map[string]any{"inboundTag": "old-tag"}, + }} + if got := replaceInboundTagInOutbounds(outbounds, "old-tag", "new-tag"); got != tt.want { + t.Errorf("changed = %v, want %v", got, tt.want) + } + settings := outbounds[0].(map[string]any)["settings"].(map[string]any) + if got := settings["inboundTag"]; got != tt.wantTag { + t.Errorf("inboundTag = %v, want %v", got, tt.wantTag) + } + }) + } +} + +func TestRemoveInboundTagFromOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) { + tests := []struct { + name string + protocol any + want bool + wantTag any + }{ + {"canonical loopback is cleared", "loopback", true, nil}, + {"capitalised loopback is cleared", "Loopback", true, nil}, + {"uppercase loopback is cleared", "LOOPBACK", true, nil}, + {"another protocol keeps its tag", "vmess", false, "gone-tag"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outbounds := []any{map[string]any{ + "protocol": tt.protocol, + "settings": map[string]any{"inboundTag": "gone-tag"}, + }} + if got := removeInboundTagFromOutbounds(outbounds, "gone-tag"); got != tt.want { + t.Errorf("changed = %v, want %v", got, tt.want) + } + settings := outbounds[0].(map[string]any)["settings"].(map[string]any) + got, ok := settings["inboundTag"] + if tt.wantTag == nil { + if ok { + t.Errorf("inboundTag = %v, want the key gone", got) + } + return + } + if !ok || got != tt.wantTag { + t.Errorf("inboundTag = %v (present=%v), want %v", got, ok, tt.wantTag) + } + }) + } +} + func findRuleByOutbound(t *testing.T, template, outbound string) map[string]any { t.Helper() for _, rule := range routingRulesFromTemplate(t, template) {