Files
3x-ui/internal/sub/clash_external_vless_test.go
T
Duxxie c3394fefed feat(sub): opt-in dns and sniffer from remote Clash rules (#6868)
Public Mihomo templates ship rules, rule-providers and proxy-groups together
with a dns block those rules depend on (fake-ip ranges, nameserver policy).
Pasting a template inline keeps its dns, but pointing the global routing rules
at its URL dropped it, so URL users had to give up auto-refresh to keep it.

A new switch, subClashRemoteDNS (off by default), also takes the remote dns and
sniffer maps. It is opt-in because a remote dns block decides where clients send
every lookup. A malformed dns or sniffer value is skipped, not the whole template.
The component-test teardown now outlasts AntD Form's 10ms error debounce, which
otherwise fired after jsdom was gone.
2026-10-24 04:21:04 +02:00

125 lines
4.3 KiB
Go

package sub
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
const clashExternalVlessLink = "vless://22222222-2222-4222-8222-222222222222@198.51.100.9:443?type=tcp&security=reality&sni=example.com&pbk=test-public-key&sid=ab12&fp=chrome&flow=xtls-rprx-vision"
func TestClashExternalVlessEncryption(t *testing.T) {
svc := NewSubClashService(false, "", false, &SubService{})
for _, tc := range []struct {
name string
query string
want string
}{
{"encrypted", "&encryption=" + url.QueryEscape(testMlkemEncryption), testMlkemEncryption},
{"trimmed", "&encryption=" + url.QueryEscape(" \t"+testMlkemEncryption+" \n"), testMlkemEncryption},
{"none", "&encryption=none", ""},
{"trimmed none", "&encryption=%20none%20", ""},
{"empty", "&encryption=", ""},
{"whitespace", "&encryption=%20%09", ""},
{"missing", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
proxy := svc.clashProxyFromExternal(clashExternalVlessLink+tc.query+"#external", "external")
if proxy == nil {
t.Fatal("expected a VLESS proxy")
}
if got, exists := proxy["encryption"]; tc.want == "" {
if exists {
t.Errorf("plain VLESS encryption should be omitted, got %v", got)
}
} else if got != tc.want {
t.Errorf("encryption = %v, want %q", got, tc.want)
}
for key, want := range map[string]any{
"type": "vless", "uuid": "22222222-2222-4222-8222-222222222222",
"server": "198.51.100.9", "port": 443, "network": "tcp",
"flow": "xtls-rprx-vision", "tls": true, "servername": "example.com",
} {
if got := proxy[key]; got != want {
t.Errorf("%s = %v, want %v", key, got, want)
}
}
if _, exists := proxy["packet-encoding"]; exists {
t.Error("VLESS encryption must not be exported as packet-encoding")
}
})
}
}
// Regression for #6572: both pasted links and fetched subscriptions must keep
// encryption when their nodes are merged with the client's local inbound.
func TestClashMergedExternalVlessEncryption(t *testing.T) {
link := clashExternalVlessLink + "&encryption=" + url.QueryEscape(testMlkemEncryption) + "#external"
for _, source := range []string{"link", "subscription-plain", "subscription-base64"} {
t.Run(source, func(t *testing.T) {
seedSubDB(t)
resetSubscriptionCache(t)
seedSubInbound(t, "merged-vless", "local", 10001, 1, wsTLSStream)
db := database.GetDB()
var client model.ClientRecord
if err := db.Where("email = ?", "local@e").First(&client).Error; err != nil {
t.Fatal(err)
}
entry := model.ClientExternalLink{ClientId: client.Id, Kind: model.ExternalLinkKindLink, Value: link}
if source != "link" {
body := link + "\n"
if source == "subscription-base64" {
body = base64.StdEncoding.EncodeToString([]byte(body))
}
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
previousClient := subscriptionHTTPClient
subscriptionHTTPClient = srv.Client()
t.Cleanup(func() { subscriptionHTTPClient = previousClient })
entry.Kind = model.ExternalLinkKindSubscription
entry.Value = srv.URL
}
if err := db.Create(&entry).Error; err != nil {
t.Fatal(err)
}
w := fetchClashSub(t, clashSubRouter(t), "/clash/merged-vless?view=raw")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var config struct {
Proxies []struct {
UUID string `yaml:"uuid"`
Encryption string `yaml:"encryption"`
} `yaml:"proxies"`
}
if err := yaml.Unmarshal(w.Body.Bytes(), &config); err != nil {
t.Fatalf("decode Clash YAML: %v", err)
}
if len(config.Proxies) != 2 {
t.Fatalf("expected local and external proxies, got %#v", config.Proxies)
}
want := map[string]string{client.UUID: "", "22222222-2222-4222-8222-222222222222": testMlkemEncryption}
for _, proxy := range config.Proxies {
encryption, ok := want[proxy.UUID]
if !ok || proxy.Encryption != encryption {
t.Errorf("unexpected proxy: %#v", proxy)
}
delete(want, proxy.UUID)
}
if len(want) != 0 {
t.Errorf("missing proxies: %v", want)
}
})
}
}