|
@@ -0,0 +1,124 @@
|
|
|
|
|
+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://[email protected]: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, "", &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)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|