fix(sub): preserve external VLESS encryption in Clash subscriptions (#6576)

* fix(sub): preserve external VLESS encryption in Clash subscriptions

Copy non-empty, non-none encryption from parsed external VLESS settings,
matching local proxy export. This prevents merged Clash/Mihomo subscriptions
from losing the encryption parameters of externally added nodes.

Cover encryption normalization and omission, plus merged YAML from pasted
links and HTTPS subscriptions containing plain or Base64 share-link lists.

Validation: regression cases fail before the fix and pass after it; the full
subscription package and go build ./... pass. Four unrelated packages still
fail on Windows, with the same failures reproduced using the original code.

Refs: MHSanaei/3x-ui#6572

* test(nodes): wait for chart effects in history panel assertions

The DOM can commit its accessible labels before Sparkline updates the refs used by uPlot range callbacks. Wait for the existing assertions together so the test does not read the empty-data range.

Reproduced the original CI failure locally on attempt 5. The fixed test passed 12 consecutive runs; lint, format and TypeScript checks pass. The full frontend suite passed 1607 of 1608 tests, including Storybook. The unrelated input-number guard fails on Windows because execFileSync cannot launch the extensionless oxlint shim (ENOENT); invoking oxlint.cmd reports all three expected diagnostics.
This commit is contained in:
SakikoTogawa
2026-09-27 02:31:50 +08:00
committed by GitHub
parent 0dec3d65ba
commit 5d41e65a3c
3 changed files with 146 additions and 13 deletions
+16 -13
View File
@@ -37,18 +37,21 @@ describe('NodeHistoryPanel', () => {
render(<NodeHistoryPanel node={{ id: 7 }} />);
await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(4));
expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
'40%',
'60%',
'512',
'200',
]);
expect(plots.map((p) => p.scales.y.range())).toEqual([
[0, 100],
[0, 100],
[0, 512 * 1.1],
[0, 200 * 1.1],
]);
// Sparkline updates its chart refs in a passive effect after the DOM commits.
await waitFor(() => {
expect(screen.getAllByRole('img')).toHaveLength(4);
expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual([
'40%',
'60%',
'512',
'200',
]);
expect(plots.map((p) => p.scales.y.range())).toEqual([
[0, 100],
[0, 100],
[0, 512 * 1.1],
[0, 200 * 1.1],
]);
});
});
});
+6
View File
@@ -55,6 +55,12 @@ func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[strin
if flow, _ := settings["flow"].(string); flow != "" {
proxy["flow"] = flow
}
if encryption, ok := settings["encryption"].(string); ok {
encryption = strings.TrimSpace(encryption)
if encryption != "" && encryption != "none" {
proxy["encryption"] = encryption
}
}
case "trojan":
server := firstServer(settings)
if server == nil {
+124
View File
@@ -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://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, "", &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)
}
})
}
}