fix(converter): fill servername and fingerprint for CDN VLESS/WS

Client YAML for domain+WS on 443 was missing TLS SNI/servername and
client-fingerprint, which breaks Cloudflare edge handshake.
This commit is contained in:
kazeyukiro committed 2026-09-30 08:25:53 +00:00
1 parent d2a80f5f80
commit b3170423ba
2 files changed
+122

No files matched your search

+79
View File
@@ -281,6 +281,8 @@ func listenerToProxies(l models.Listener, server string, credentials []user.Cred
copyOption(p, opts, "alterId")
}
applyClientWrappers(p, opts)
ensureClientAccessTLS(p, l, server)
applyClientSkipCertVerify(p, opts, server, l.ID)
result = append(result, p)
}
case "trojan":
@@ -303,6 +305,8 @@ func listenerToProxies(l models.Listener, server string, credentials []user.Cred
if value, ok := opts["ss-option"]; ok {
p["ss-opts"] = value
}
ensureClientAccessTLS(p, l, server)
applyClientSkipCertVerify(p, opts, server, l.ID)
result = append(result, p)
}
case "hysteria2":
@@ -852,6 +856,81 @@ func normalizeTUICToken(token interface{}) interface{} {
// applyClientSkipCertVerify sets skip-cert-verify on a client proxy map using
// smart cert detection (panel self-signed, host match, explicit override).
// ensureClientAccessTLS fills TLS hostname / fingerprint for CDN and domain
// access profiles. Listener config often omits sni (panel-only / stripped),
// while clients still need servername when connecting via Cloudflare or a
// public domain on 443.
func ensureClientAccessTLS(p map[string]interface{}, l models.Listener, server string) {
if p == nil {
return
}
accessSNI := strings.TrimSpace(l.AccessSNI)
publicHost := netutil.NormalizeHost(l.PublicHost)
hint := accessSNI
if hint == "" {
hint = publicHost
}
if hint == "" {
hint = strings.TrimSpace(server)
}
publicPort := strings.TrimSpace(l.PublicPort)
if publicPort == "" {
publicPort = strings.TrimSpace(l.Port)
}
network, _ := p["network"].(string)
domainHint := hint != "" && net.ParseIP(hint) == nil
// WS + public domain (+ common CDN ports) ⇒ client talks TLS to the edge.
if network == "ws" && domainHint && (publicPort == "443" || publicPort == "8443" || publicPort == "2053" || publicPort == "2083" || publicPort == "2087" || publicPort == "2096") {
p["tls"] = true
}
tlsOn := false
switch v := p["tls"].(type) {
case bool:
tlsOn = v
case string:
tlsOn = strings.EqualFold(v, "true")
}
if !tlsOn {
return
}
if p["servername"] == nil && p["sni"] == nil && hint != "" {
p["servername"] = hint
p["sni"] = hint
} else if p["servername"] == nil {
if s, ok := p["sni"].(string); ok && strings.TrimSpace(s) != "" {
p["servername"] = strings.TrimSpace(s)
}
} else if p["sni"] == nil {
if s, ok := p["servername"].(string); ok && strings.TrimSpace(s) != "" {
p["sni"] = strings.TrimSpace(s)
}
}
if p["client-fingerprint"] == nil {
if fp, ok := p["fingerprint"].(string); ok && strings.TrimSpace(fp) != "" {
p["client-fingerprint"] = strings.TrimSpace(fp)
} else {
p["client-fingerprint"] = "chrome"
}
}
// WS Host header for CDN when still empty.
if network == "ws" {
ws, _ := p["ws-opts"].(map[string]interface{})
if ws == nil {
ws = map[string]interface{}{"path": "/"}
p["ws-opts"] = ws
}
headers, _ := ws["headers"].(map[string]interface{})
if headers == nil {
headers = map[string]interface{}{}
}
if h, _ := headers["Host"].(string); strings.TrimSpace(h) == "" && hint != "" {
headers["Host"] = hint
ws["headers"] = headers
}
}
}
func applyClientSkipCertVerify(p, opts map[string]interface{}, connectHost string, listenerID uint) {
if p == nil {
return
@@ -0,0 +1,43 @@
package converter
import (
"testing"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/user"
)
func TestListenerToProxiesCDNWSFillsServername(t *testing.T) {
l := models.Listener{
Name: "VLESS-CF",
Protocol: "vless",
Port: "2053",
PublicHost: "dzx1283.cc.cd",
PublicPort: "443",
AccessSNI: "dzx1283.cc.cd",
Enabled: true,
Config: `{"ws-path":"/ws","allow-insecure":true}`,
}
proxies, err := listenerToProxies(l, "dzx1283.cc.cd", []user.Credential{{UUID: "f3b71a1a-ff1e-45a4-9de9-c672aa347f62", Username: "admin"}})
if err != nil {
t.Fatal(err)
}
if len(proxies) != 1 {
t.Fatalf("len=%d", len(proxies))
}
p := proxies[0]
if p["tls"] != true {
t.Fatalf("tls: %#v", p["tls"])
}
if p["servername"] != "dzx1283.cc.cd" {
t.Fatalf("servername: %#v", p["servername"])
}
if p["client-fingerprint"] != "chrome" {
t.Fatalf("fingerprint: %#v", p["client-fingerprint"])
}
ws, _ := p["ws-opts"].(map[string]interface{})
headers, _ := ws["headers"].(map[string]interface{})
if headers["Host"] != "dzx1283.cc.cd" {
t.Fatalf("Host: %#v", headers)
}
}