diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 937a8ec69..13c342bc1 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -458,6 +458,11 @@ export function parseVmessLink(link: string): Raw | null { tls.serverName = json.sni ?? ''; tls.fingerprint = json.fp ?? ''; if (json.alpn) tls.alpn = (json.alpn as string).split(','); + // The vmess object names the certificate checks the url-param protocols + // pass through applySecurityParams, under the same short names. + if (typeof json.ech === 'string') tls.echConfigList = json.ech; + if (typeof json.vcn === 'string') tls.verifyPeerCertByName = json.vcn; + if (typeof json.pcs === 'string') tls.pinnedPeerCertSha256 = json.pcs; } const port = Number(json.port) || 443; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 72ef0626f..66c0e29b6 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -57,6 +57,34 @@ describe('parseVmessLink', () => { expect((stream.tlsSettings as Record).alpn).toEqual(['h2', 'http/1.1']); }); + // The exporter writes ech/vcn/pcs into the vmess object, so the importer has + // to read them instead of leaving the tls checks it seeded empty. + it('keeps the ech, vcn and pcs certificate checks', () => { + const json = { + v: '2', + ps: 'pinned-vmess', + add: '1.2.3.4', + port: 8443, + id: '11111111-2222-4333-8444-555555555555', + scy: 'auto', + net: 'tcp', + tls: 'tls', + sni: 'vmess.example.com', + fp: 'chrome', + ech: 'AEX+DQBB', + vcn: 'vcn.example.com', + pcs: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + }; + const link = `vmess://${Base64.encode(JSON.stringify(json))}`; + const out = parseVmessLink(link); + expect(out).not.toBeNull(); + const stream = out?.streamSettings as Record; + const tls = stream.tlsSettings as Record; + expect(tls.echConfigList).toBe('AEX+DQBB'); + expect(tls.verifyPeerCertByName).toBe('vcn.example.com'); + expect(tls.pinnedPeerCertSha256).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='); + }); + it('returns null for non-vmess links', () => { expect(parseVmessLink('vless://x@y:1')).toBeNull(); }); diff --git a/internal/sub/vmess_tls_import_test.go b/internal/sub/vmess_tls_import_test.go new file mode 100644 index 000000000..a3da2e6f1 --- /dev/null +++ b/internal/sub/vmess_tls_import_test.go @@ -0,0 +1,60 @@ +package sub + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/link" +) + +// The vmess object carries the certificate checks the panel exported, so +// importing that same link has to rebuild them instead of dropping them. +func TestVmessTLSVerifyFieldsSurviveExportImport(t *testing.T) { + in := &model.Inbound{ + Id: 940002, Listen: "203.0.113.1", Port: 8443, Protocol: model.VMESS, + Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}]}`, + StreamSettings: `{"network":"tcp","security":"tls","tcpSettings":{"header":{"type":"none"}},` + + `"tlsSettings":{"serverName":"vmess.example.com","alpn":["h2"],"settings":{` + + `"fingerprint":"chrome","echConfigList":"AEX+DQBB","verifyPeerCertByName":"vcn.example.com",` + + `"pinnedPeerCertSha256":["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}}}`, + } + exported := (&SubService{}).genVmessLink(in, "user") + if exported == "" { + t.Fatal("genVmessLink produced nothing") + } + + parsed, err := link.ParseLink(exported) + if err != nil { + t.Fatalf("ParseLink: %v", err) + } + raw, err := json.Marshal(parsed.Outbound["streamSettings"]) + if err != nil { + t.Fatalf("marshal stream: %v", err) + } + var stream map[string]any + if err := json.Unmarshal(raw, &stream); err != nil { + t.Fatalf("stream json: %v", err) + } + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + if tlsSettings == nil { + t.Fatalf("no tlsSettings: %s", raw) + } + + // The core reads these joined strings in its config, as applySecurity writes them. + for field, want := range map[string]string{ + "serverName": "vmess.example.com", + "fingerprint": "chrome", + "echConfigList": "AEX+DQBB", + "verifyPeerCertByName": "vcn.example.com", + "pinnedPeerCertSha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + } { + if tlsSettings[field] != want { + t.Errorf("tlsSettings[%q] = %v, want %q", field, tlsSettings[field], want) + } + } + alpn, _ := tlsSettings["alpn"].([]any) + if len(alpn) != 1 || alpn[0] != "h2" { + t.Errorf("alpn = %v, want [h2]", tlsSettings["alpn"]) + } +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index f149e619e..7c5d004b6 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -205,6 +205,11 @@ func parseVmess(link string) (*ParseResult, error) { if alpn := getString(j, "alpn", ""); alpn != "" { tls["alpn"] = splitComma(alpn) } + // The vmess object names the certificate checks v2rayN does the same way + // the url-param protocols name them in applySecurity. + tls["echConfigList"] = getString(j, "ech", "") + tls["verifyPeerCertByName"] = getString(j, "vcn", "") + tls["pinnedPeerCertSha256"] = getString(j, "pcs", "") } port := num(j["port"])