From 543fb5786e9d875736c86effd2710357afc54a68 Mon Sep 17 00:00:00 2001 From: WuxiVIP Date: Fri, 14 Aug 2026 13:11:03 +0800 Subject: [PATCH 1/3] feat(proxy): support VLESS Reality Vision Encode the Vision flow addon and honor Reality ClientHello fingerprints. Reject presets without X25519 TLS 1.3 key shares and preserve explicit ALPN. --- backend/go.mod | 5 + backend/go.sum | 10 + backend/internal/pkg/tunnelproxy/reality.go | 234 ++++++++++++++++++ .../internal/pkg/tunnelproxy/tunnelproxy.go | 131 ++++++++-- .../pkg/tunnelproxy/tunnelproxy_test.go | 138 ++++++++++- 5 files changed, 502 insertions(+), 16 deletions(-) create mode 100644 backend/internal/pkg/tunnelproxy/reality.go diff --git a/backend/go.mod b/backend/go.mod index ea822164d..764639adf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,7 +14,10 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/metacubex/sing v0.5.7 + github.com/metacubex/sing-vmess v0.2.5 github.com/redis/go-redis/v9 v9.21.0 + github.com/refraction-networking/utls v1.6.7 github.com/shadowsocks/go-shadowsocks2 v0.1.5 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -41,6 +44,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -55,6 +59,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gofrs/uuid/v5 v5.3.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index c4c442f60..066a5245c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -36,6 +36,8 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -80,6 +82,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -128,6 +132,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= +github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= +github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE= +github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -154,6 +162,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM= +github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= diff --git a/backend/internal/pkg/tunnelproxy/reality.go b/backend/internal/pkg/tunnelproxy/reality.go new file mode 100644 index 000000000..e2358ae7a --- /dev/null +++ b/backend/internal/pkg/tunnelproxy/reality.go @@ -0,0 +1,234 @@ +package tunnelproxy + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "time" + + "github.com/Asutorufa/yuhaiin/pkg/net/netapi" + utls "github.com/refraction-networking/utls" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +// realityProxy follows the Xray Reality client handshake used by yuhaiin, but +// keeps the ClientHello selectable so Clash client-fingerprint values are not +// silently replaced with Chrome. +type realityProxy struct { + netapi.EmptyDispatch + dialer netapi.Proxy + serverName string + publicKey *ecdh.PublicKey + shortID [8]byte + helloID utls.ClientHelloID + alpn []string +} + +func newRealityProxy(config Config, dialer netapi.Proxy) (netapi.Proxy, error) { + publicKeyBytes, err := base64.RawURLEncoding.DecodeString(config.RealityPublicKey) + if err != nil { + return nil, fmt.Errorf("解析 Reality public key: %w", err) + } + publicKey, err := ecdh.X25519().NewPublicKey(publicKeyBytes) + if err != nil { + return nil, errors.New("Reality public key 无效") + } + var shortID [8]byte + decodedLength, err := hex.Decode(shortID[:], []byte(config.RealityShortID)) + if err != nil || decodedLength > len(shortID) { + return nil, errors.New("Reality short ID 无效") + } + helloID, ok := realityClientHelloID(config.ClientFingerprint) + if !ok { + return nil, fmt.Errorf("VLESS Reality 暂不支持客户端指纹 %q", config.ClientFingerprint) + } + return &realityProxy{ + dialer: dialer, serverName: config.ServerName, publicKey: publicKey, + shortID: shortID, helloID: helloID, alpn: append([]string(nil), config.ALPN...), + }, nil +} + +func realityClientHelloID(fingerprint string) (utls.ClientHelloID, bool) { + switch fingerprint { + case "chrome": + return utls.HelloChrome_Auto, true + case "firefox": + return utls.HelloFirefox_Auto, true + case "safari": + return utls.HelloSafari_Auto, true + case "ios": + return utls.HelloIOS_Auto, true + case "edge": + return utls.HelloEdge_Auto, true + case "qq": + return utls.HelloQQ_Auto, true + default: + return utls.ClientHelloID{}, false + } +} + +func (p *realityProxy) Conn(ctx context.Context, address netapi.Address) (net.Conn, error) { + connection, err := p.dialer.Conn(ctx, address) + if err != nil { + return nil, err + } + secure, err := p.handshake(ctx, connection) + if err != nil { + _ = connection.Close() + return nil, fmt.Errorf("Reality 握手: %w", err) + } + return secure, nil +} + +func (p *realityProxy) PacketConn(context.Context, netapi.Address) (net.PacketConn, error) { + return nil, errors.New("Reality 隧道不支持 UDP") +} + +func (p *realityProxy) handshake(ctx context.Context, connection net.Conn) (net.Conn, error) { + verifier := &realityVerifier{serverName: p.serverName} + secure, err := buildRealityClientHello(connection, p.serverName, p.alpn, p.helloID, verifier.verifyPeerCertificate) + if err != nil { + return nil, err + } + hello := secure.HandshakeState.Hello + privateKey := secure.HandshakeState.State13.EcdheKey + + hello.SessionId = make([]byte, 32) + copy(hello.Raw[39:], hello.SessionId) + binary.BigEndian.PutUint64(hello.SessionId, uint64(time.Now().Unix())) + hello.SessionId[0] = 1 + hello.SessionId[1] = 8 + hello.SessionId[2] = 1 + copy(hello.SessionId[8:], p.shortID[:]) + + peerKey, err := privateKey.ECDH(p.publicKey) + if err != nil { + return nil, fmt.Errorf("Reality ECDH: %w", err) + } + verifier.authKey = peerKey + if _, err := hkdf.New(sha256.New, peerKey, hello.Random[:20], []byte("REALITY")).Read(peerKey); err != nil { + return nil, err + } + var aead cipher.AEAD + if realityPrefersAESGCM(hello.CipherSuites) { + block, cipherErr := aes.NewCipher(peerKey) + if cipherErr != nil { + return nil, cipherErr + } + aead, err = cipher.NewGCM(block) + } else { + aead, err = chacha20poly1305.New(peerKey) + } + if err != nil { + return nil, err + } + aead.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw) + copy(hello.Raw[39:], hello.SessionId) + + if err := secure.HandshakeContext(ctx); err != nil { + return nil, err + } + if !verifier.verified { + return nil, errors.New("Reality 身份验证失败") + } + return secure, nil +} + +func buildRealityClientHello(connection net.Conn, serverName string, alpn []string, helloID utls.ClientHelloID, verify func([][]byte, [][]*x509.Certificate) error) (*utls.UConn, error) { + config := &utls.Config{ + ServerName: serverName, InsecureSkipVerify: true, SessionTicketsDisabled: true, + NextProtos: append([]string(nil), alpn...), VerifyPeerCertificate: verify, + } + secure := utls.UClient(connection, config, helloID) + if err := secure.BuildHandshakeState(); err != nil { + return nil, err + } + if secure.HandshakeState.Hello == nil || len(secure.HandshakeState.Hello.Raw) < 71 { + return nil, errors.New("Reality ClientHello 无效") + } + if len(alpn) != 0 { + patched := false + for _, extension := range secure.Extensions { + if alpnExtension, ok := extension.(*utls.ALPNExtension); ok { + alpnExtension.AlpnProtocols = append([]string(nil), alpn...) + patched = true + break + } + } + if !patched { + secure.Extensions = append(secure.Extensions, &utls.ALPNExtension{AlpnProtocols: append([]string(nil), alpn...)}) + } + config.NextProtos = append([]string(nil), alpn...) + secure.HandshakeState.Hello.AlpnProtocols = append([]string(nil), alpn...) + } + privateKey := secure.HandshakeState.State13.EcdheKey + if privateKey == nil || privateKey.Curve() != ecdh.X25519() { + return nil, fmt.Errorf("Reality 客户端指纹 %q 未提供 X25519 TLS 1.3 key share", helloID.Client) + } + return secure, nil +} + +func realityPrefersAESGCM(cipherSuites []uint16) bool { + for _, cipherSuite := range cipherSuites { + switch cipherSuite { + case utls.TLS_AES_128_GCM_SHA256, utls.TLS_AES_256_GCM_SHA384, + utls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, utls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + utls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, utls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: + return true + case utls.TLS_CHACHA20_POLY1305_SHA256, utls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + utls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: + return false + } + } + return false +} + +type realityVerifier struct { + serverName string + authKey []byte + verified bool +} + +func (v *realityVerifier) verifyPeerCertificate(rawCertificates [][]byte, _ [][]*x509.Certificate) error { + certificates := make([]*x509.Certificate, 0, len(rawCertificates)) + for _, rawCertificate := range rawCertificates { + certificate, err := x509.ParseCertificate(rawCertificate) + if err != nil { + return err + } + certificates = append(certificates, certificate) + } + if len(certificates) == 0 { + return errors.New("Reality 服务端未返回证书") + } + if publicKey, ok := certificates[0].PublicKey.(ed25519.PublicKey); ok { + digest := hmac.New(sha512.New, v.authKey) + _, _ = digest.Write(publicKey) + if bytes.Equal(digest.Sum(nil), certificates[0].Signature) { + v.verified = true + return nil + } + } + options := x509.VerifyOptions{ + DNSName: v.serverName, Intermediates: x509.NewCertPool(), CurrentTime: time.Now(), + } + for _, certificate := range certificates[1:] { + options.Intermediates.AddCert(certificate) + } + _, err := certificates[0].Verify(options) + return err +} diff --git a/backend/internal/pkg/tunnelproxy/tunnelproxy.go b/backend/internal/pkg/tunnelproxy/tunnelproxy.go index c6314036f..de0ac3409 100644 --- a/backend/internal/pkg/tunnelproxy/tunnelproxy.go +++ b/backend/internal/pkg/tunnelproxy/tunnelproxy.go @@ -16,11 +16,15 @@ import ( "github.com/Asutorufa/yuhaiin/pkg/net/netapi" "github.com/Asutorufa/yuhaiin/pkg/net/proxy/trojan" - "github.com/Asutorufa/yuhaiin/pkg/net/proxy/vless" + visionproxy "github.com/Asutorufa/yuhaiin/pkg/net/proxy/vision" + yuhaiinvless "github.com/Asutorufa/yuhaiin/pkg/net/proxy/vless" "github.com/Asutorufa/yuhaiin/pkg/net/proxy/vmess" "github.com/Asutorufa/yuhaiin/pkg/protos/node/protocol" "github.com/coder/websocket" "github.com/google/uuid" + singvmess "github.com/metacubex/sing-vmess" + singvless "github.com/metacubex/sing-vmess/vless" + singmetadata "github.com/metacubex/sing/common/metadata" sscore "github.com/shadowsocks/go-shadowsocks2/core" "github.com/shadowsocks/go-shadowsocks2/socks" ) @@ -34,9 +38,15 @@ type Config struct { Cipher string Transport string TLS bool + Security string + Flow string ServerName string Insecure bool ALPN []string + RealityPublicKey string + RealityShortID string + ClientFingerprint string + SpiderX string WebSocketHost string WebSocketPath string CanonicalProxyURL string @@ -111,9 +121,7 @@ func parseUserInfoProxy(value string) (Config, error) { if transport != "tcp" && transport != "ws" { return Config{}, fmt.Errorf("暂不支持 %s 传输", transport) } - if flow := strings.TrimSpace(query.Get("flow")); flow != "" { - return Config{}, fmt.Errorf("暂不支持 VLESS flow %q", flow) - } + flow := strings.ToLower(strings.TrimSpace(query.Get("flow"))) if scheme == "vless" { parsedUUID, err := uuid.Parse(credential) if err != nil { @@ -126,16 +134,39 @@ func parseUserInfoProxy(value string) (Config, error) { } security := strings.ToLower(strings.TrimSpace(query.Get("security"))) tlsEnabled := scheme == "trojan" + securityMode := "none" + if tlsEnabled { + securityMode = "tls" + } switch security { case "", "tls": if security == "tls" { tlsEnabled = true + securityMode = "tls" } case "none": tlsEnabled = false + securityMode = "none" + case "reality": + if scheme != "vless" { + return Config{}, errors.New("Reality security 当前仅支持 VLESS") + } + if transport != "tcp" { + return Config{}, errors.New("VLESS Reality 当前仅支持 TCP 传输") + } + tlsEnabled = true + securityMode = "reality" default: return Config{}, fmt.Errorf("暂不支持 %s security %q", strings.ToUpper(scheme), security) } + if flow != "" { + if scheme != "vless" || flow != "xtls-rprx-vision" { + return Config{}, fmt.Errorf("暂不支持 VLESS flow %q", flow) + } + if transport != "tcp" || (securityMode != "tls" && securityMode != "reality") { + return Config{}, errors.New("VLESS Vision 需要 TCP 和 TLS 或 Reality security") + } + } if headerType := strings.ToLower(strings.TrimSpace(query.Get("headerType"))); headerType != "" && headerType != "none" { return Config{}, fmt.Errorf("暂不支持 %s headerType %q", strings.ToUpper(scheme), headerType) } @@ -144,6 +175,23 @@ func parseUserInfoProxy(value string) (Config, error) { return Config{}, err } serverName := firstNonEmpty(query.Get("sni"), query.Get("peer"), parsed.Hostname()) + realityPublicKey := firstNonEmpty(query.Get("pbk"), query.Get("public-key"), query.Get("publicKey")) + realityShortID := firstNonEmpty(query.Get("sid"), query.Get("short-id"), query.Get("shortId")) + fingerprint := strings.ToLower(firstNonEmpty(query.Get("fp"), query.Get("fingerprint"), query.Get("client-fingerprint"))) + spiderX := firstNonEmpty(query.Get("spx"), query.Get("spider-x"), query.Get("spiderX")) + if securityMode == "reality" { + if realityPublicKey == "" { + return Config{}, errors.New("VLESS Reality public key 不能为空") + } + if fingerprint == "" { + fingerprint = "chrome" + } + if _, supported := realityClientHelloID(fingerprint); !supported { + return Config{}, fmt.Errorf("VLESS Reality 暂不支持客户端指纹 %q", fingerprint) + } + } else if realityPublicKey != "" || realityShortID != "" || fingerprint != "" || spiderX != "" { + return Config{}, errors.New("Reality 参数只能用于 Reality security") + } wsHost := firstNonEmpty(query.Get("host"), serverName) if err := validateWebSocketHost(wsHost); transport == "ws" && err != nil { return Config{}, err @@ -159,8 +207,9 @@ func parseUserInfoProxy(value string) (Config, error) { } config := Config{ Scheme: scheme, Server: server, Credential: credential, Transport: transport, - TLS: tlsEnabled, ServerName: serverName, Insecure: insecure, - ALPN: splitList(query.Get("alpn")), WebSocketHost: wsHost, WebSocketPath: wsPath, + TLS: tlsEnabled, Security: securityMode, Flow: flow, ServerName: serverName, Insecure: insecure, + ALPN: splitList(query.Get("alpn")), RealityPublicKey: realityPublicKey, RealityShortID: realityShortID, + ClientFingerprint: fingerprint, SpiderX: spiderX, WebSocketHost: wsHost, WebSocketPath: wsPath, } config.CanonicalProxyURL = canonicalUserInfoProxyURL(config) if err := validateConfig(config); err != nil { @@ -172,15 +221,29 @@ func parseUserInfoProxy(value string) (Config, error) { func canonicalUserInfoProxyURL(config Config) string { query := make(url.Values) query.Set("type", config.Transport) - if config.TLS { - query.Set("security", "tls") - } else { - query.Set("security", "none") + security := config.Security + if security == "" { + security = "none" + if config.TLS { + security = "tls" + } } + query.Set("security", security) if config.Scheme == "vless" { query.Set("encryption", "none") } + if config.Flow != "" { + query.Set("flow", config.Flow) + } query.Set("sni", config.ServerName) + if security == "reality" { + query.Set("pbk", config.RealityPublicKey) + query.Set("sid", config.RealityShortID) + query.Set("fp", config.ClientFingerprint) + if config.SpiderX != "" { + query.Set("spx", config.SpiderX) + } + } if config.Insecure { query.Set("allowInsecure", "1") } @@ -456,6 +519,12 @@ func buildProxy(config Config) (netapi.Proxy, error) { var current netapi.Proxy = &serverDialer{address: config.Server} if config.Transport == "ws" { current = &websocketProxy{config: config, dialer: current} + } else if config.Security == "reality" { + var err error + current, err = newRealityProxy(config, current) + if err != nil { + return nil, fmt.Errorf("创建 Reality 客户端: %w", err) + } } else if config.TLS { current = &tlsProxy{config: config, dialer: current} } @@ -467,7 +536,7 @@ func buildProxy(config Config) (netapi.Proxy, error) { case "vless": protocolConfig := &protocol.Vless{} protocolConfig.SetUuid(config.Credential) - return newOwnedVLESSProxy(protocolConfig, current) + return newOwnedVLESSProxy(protocolConfig, current, config.Flow) case "vmess": protocolConfig := &protocol.Vmess{} protocolConfig.SetUuid(config.Credential) @@ -487,13 +556,23 @@ type ownedVLESSProxy struct { netapi.EmptyDispatch config *protocol.Vless dialer netapi.Proxy + flow string + uuid [16]byte } -func newOwnedVLESSProxy(config *protocol.Vless, dialer netapi.Proxy) (netapi.Proxy, error) { - if _, err := vless.NewClient(config, dialer); err != nil { +func newOwnedVLESSProxy(config *protocol.Vless, dialer netapi.Proxy, flow string) (netapi.Proxy, error) { + if _, err := yuhaiinvless.NewClient(config, dialer); err != nil { return nil, err } - return &ownedVLESSProxy{config: config, dialer: dialer}, nil + proxy := &ownedVLESSProxy{config: config, dialer: dialer, flow: flow} + if flow == "xtls-rprx-vision" { + userID, err := uuid.Parse(config.GetUuid()) + if err != nil { + return nil, errors.New("VLESS Vision UUID 无效") + } + copy(proxy.uuid[:], userID[:]) + } + return proxy, nil } func (p *ownedVLESSProxy) Conn(ctx context.Context, address netapi.Address) (net.Conn, error) { @@ -501,7 +580,20 @@ func (p *ownedVLESSProxy) Conn(ctx context.Context, address netapi.Address) (net if err != nil { return nil, err } - client, err := vless.NewClient(p.config, &singleConnectionProxy{connection: connection}) + if p.flow == "xtls-rprx-vision" { + result, requestErr := newVisionVLESSConn(connection, address, p.uuid, p.flow) + if requestErr != nil { + _ = connection.Close() + return nil, fmt.Errorf("发送 VLESS Vision 请求: %w", requestErr) + } + visionConnection, visionErr := visionproxy.NewVisionConn(result, connection, p.uuid) + if visionErr != nil { + _ = result.Close() + return nil, fmt.Errorf("创建 VLESS Vision 连接: %w", visionErr) + } + return visionConnection, nil + } + client, err := yuhaiinvless.NewClient(p.config, &singleConnectionProxy{connection: connection}) if err != nil { _ = connection.Close() return nil, err @@ -514,6 +606,15 @@ func (p *ownedVLESSProxy) Conn(ctx context.Context, address netapi.Address) (net return result, nil } +func newVisionVLESSConn(connection net.Conn, address netapi.Address, userID [16]byte, flow string) (net.Conn, error) { + destination := singmetadata.ParseSocksaddrHostPort(address.Hostname(), address.Port()) + result := singvless.NewConn(connection, userID, singvmess.CommandTCP, destination, flow) + if _, err := result.Write(nil); err != nil { + return nil, err + } + return result, nil +} + func (p *ownedVLESSProxy) PacketConn(ctx context.Context, address netapi.Address) (net.PacketConn, error) { return nil, errors.New("VLESS 隧道不支持 UDP") } diff --git a/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go b/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go index a34f86a88..c5755fd80 100644 --- a/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go +++ b/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go @@ -16,6 +16,9 @@ import ( "github.com/Asutorufa/yuhaiin/pkg/net/netapi" "github.com/Asutorufa/yuhaiin/pkg/protos/node/protocol" "github.com/coder/websocket" + "github.com/google/uuid" + singvless "github.com/metacubex/sing-vmess/vless" + utls "github.com/refraction-networking/utls" ) const testUUID = "123e4567-e89b-12d3-a456-426614174000" @@ -63,6 +66,100 @@ func TestNormalizeRemarksDoNotChangeIdentity(t *testing.T) { } } +func TestNormalizeRealityVisionBuildsDialer(t *testing.T) { + raw := "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&type=tcp&flow=xtls-rprx-vision&fp=chrome&pbk=SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8&sid=0123456789abcdef&spx=%2F" + normalized, err := Normalize(raw) + if err != nil { + t.Fatal(err) + } + config, err := Parse(normalized) + if err != nil { + t.Fatal(err) + } + if config.Security != "reality" || config.Flow != "xtls-rprx-vision" || config.RealityPublicKey == "" || config.RealityShortID != "0123456789abcdef" || config.ClientFingerprint != "chrome" || config.SpiderX != "/" { + t.Fatalf("Reality Vision config = %#v", config) + } + if _, err := NewDialer(normalized); err != nil { + t.Fatalf("construct Reality Vision dialer: %v", err) + } + for _, field := range []string{"security=reality", "flow=xtls-rprx-vision", "fp=chrome", "pbk=", "sid=0123456789abcdef", "spx=%2F"} { + if !strings.Contains(normalized, field) { + t.Fatalf("normalized Reality URL missing %q: %s", field, normalized) + } + } +} + +func TestNormalizeRealitySupportsStandardClientFingerprints(t *testing.T) { + for _, fingerprint := range []string{"chrome", "edge", "safari", "firefox", "ios", "qq"} { + t.Run(fingerprint, func(t *testing.T) { + raw := "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&type=tcp&flow=xtls-rprx-vision&pbk=SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8&sid=0123456789abcdef&fp=" + fingerprint + normalized, err := Normalize(raw) + if err != nil { + t.Fatal(err) + } + config, err := Parse(normalized) + if err != nil { + t.Fatal(err) + } + if config.ClientFingerprint != fingerprint { + t.Fatalf("client fingerprint = %q", config.ClientFingerprint) + } + }) + } +} + +func TestRealityAcceptedFingerprintsProvideX25519AndPreserveALPN(t *testing.T) { + wantedALPN := []string{"h2", "http/1.1"} + for _, fingerprint := range []string{"chrome", "edge", "safari", "firefox", "ios", "qq"} { + t.Run(fingerprint, func(t *testing.T) { + helloID, supported := realityClientHelloID(fingerprint) + if !supported { + t.Fatalf("fingerprint %q unexpectedly unsupported", fingerprint) + } + client, server := net.Pipe() + defer client.Close() + defer server.Close() + secure, err := buildRealityClientHello(client, "edge.example", wantedALPN, helloID, nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(secure.HandshakeState.Hello.AlpnProtocols, ",") != strings.Join(wantedALPN, ",") { + t.Fatalf("ClientHello ALPN = %v", secure.HandshakeState.Hello.AlpnProtocols) + } + foundALPN := false + for _, extension := range secure.Extensions { + alpnExtension, ok := extension.(*utls.ALPNExtension) + if !ok { + continue + } + foundALPN = true + if strings.Join(alpnExtension.AlpnProtocols, ",") != strings.Join(wantedALPN, ",") { + t.Fatalf("ALPN extension = %v", alpnExtension.AlpnProtocols) + } + } + if !foundALPN { + t.Fatal("ClientHello has no ALPN extension") + } + }) + } +} + +func TestRealityClientHelloWithoutTLS13KeyShareReturnsError(t *testing.T) { + for name, helloID := range map[string]utls.ClientHelloID{ + "android": utls.HelloAndroid_11_OkHttp, + "360": utls.Hello360_Auto, + } { + t.Run(name, func(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + if _, err := buildRealityClientHello(client, "edge.example", nil, helloID, nil); err == nil { + t.Fatalf("fingerprint %q unexpectedly produced a Reality key share", name) + } + }) + } +} + func TestNormalizeEquivalentShareLinksHaveOneIdentity(t *testing.T) { trojanOne, err := Normalize("trojan://secret@proxy.example:443?type=websocket&security=tls&sni=edge.example&host=edge.example&path=ws") if err != nil { @@ -107,7 +204,7 @@ func TestOwnedVLESSProxyClosesConnectionWhenInitialWriteFails(t *testing.T) { tracked := &closeTrackingConn{Conn: client} config := &protocol.Vless{} config.SetUuid(testUUID) - proxy, err := newOwnedVLESSProxy(config, &singleConnectionProxy{connection: tracked}) + proxy, err := newOwnedVLESSProxy(config, &singleConnectionProxy{connection: tracked}, "") if err != nil { t.Fatal(err) } @@ -119,6 +216,41 @@ func TestOwnedVLESSProxyClosesConnectionWhenInitialWriteFails(t *testing.T) { } } +func TestVisionVLESSRequestIncludesFlowAddon(t *testing.T) { + client, server := net.Pipe() + defer server.Close() + + var userID [16]byte + parsedUUID, err := uuid.Parse(testUUID) + if err != nil { + t.Fatal(err) + } + copy(userID[:], parsedUUID[:]) + + result := make(chan error, 1) + go func() { + connection, requestErr := newVisionVLESSConn(client, netapi.ParseDomainPort("tcp", "target.example", 443), userID, "xtls-rprx-vision") + if connection != nil { + _ = connection.Close() + } + result <- requestErr + }() + + request, err := singvless.ReadRequest(server) + if err != nil { + t.Fatal(err) + } + if request.Flow != "xtls-rprx-vision" { + t.Fatalf("VLESS flow addon = %q", request.Flow) + } + if request.Destination.Fqdn != "target.example" || request.Destination.Port != 443 { + t.Fatalf("VLESS destination = %v", request.Destination) + } + if err := <-result; err != nil { + t.Fatal(err) + } +} + func TestTunnelHandshakeContextHasIndependentDeadline(t *testing.T) { ctx, cancel := newTunnelHandshakeContext(context.Background()) defer cancel() @@ -149,6 +281,10 @@ func TestParseRejectsUnsupportedOrMalformedLinks(t *testing.T) { "tuic://user:secret@proxy.example:443", "vless://not-a-uuid@proxy.example:443?encryption=none", "vless://" + testUUID + "@proxy.example:443?encryption=none&flow=xtls-rprx-vision", + "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&pbk=invalid&sid=01", + "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&pbk=SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8&sid=01&fp=opera", + "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&pbk=SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8&sid=01&fp=android", + "vless://" + testUUID + "@proxy.example:443?encryption=none&security=reality&sni=edge.example&pbk=SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8&sid=01&fp=360", "trojan://secret@proxy.example:443?type=grpc", "trojan://secret@proxy.example:443/unexpected", "ss://" + base64.RawURLEncoding.EncodeToString([]byte("rc4-md5:secret")) + "@proxy.example:8388", From 5a84f88d1cba2a72bd27ed4c4077a3438fc0d486 Mon Sep 17 00:00:00 2001 From: WuxiVIP Date: Fri, 14 Aug 2026 13:11:21 +0800 Subject: [PATCH 2/3] feat(egress): parse Clash subscriptions Convert HTTP, SOCKS5, Trojan, VLESS, SS, and VMess entries from Clash YAML. Hysteria and TUIC remain unsupported and are skipped. --- .../application/egress/subscription.go | 5 +- .../application/egress/subscription_clash.go | 231 ++++++++++++++++++ .../application/egress/subscription_test.go | 160 ++++++++++++ 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 backend/internal/application/egress/subscription_clash.go diff --git a/backend/internal/application/egress/subscription.go b/backend/internal/application/egress/subscription.go index 42ddff988..329c4cde2 100644 --- a/backend/internal/application/egress/subscription.go +++ b/backend/internal/application/egress/subscription.go @@ -104,7 +104,7 @@ func fetchProxySubscription(ctx context.Context, value string, viaProxy string) } } request.Header.Set("Accept", "text/plain, text/*;q=0.9, */*;q=0.1") - request.Header.Set("User-Agent", "grok2api-egress-subscription/1") + request.Header.Set("User-Agent", "Clash.Meta") response, err := client.Do(request) if err != nil { return nil, err @@ -368,6 +368,9 @@ func parseProxySubscription(value string) ([]subscriptionEntry, int, error) { return entries, decodedSkipped, nil } } + if entries, clashSkipped, matched := parseClashSubscription(value); matched && len(entries) > 0 { + return entries, clashSkipped, nil + } return nil, skipped, errors.New("订阅中没有可用的代理节点") } diff --git a/backend/internal/application/egress/subscription_clash.go b/backend/internal/application/egress/subscription_clash.go new file mode 100644 index 000000000..62d902f61 --- /dev/null +++ b/backend/internal/application/egress/subscription_clash.go @@ -0,0 +1,231 @@ +package egress + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type clashSubscription struct { + Proxies []clashProxy `yaml:"proxies"` +} + +type clashProxy struct { + Type string `yaml:"type"` + Server string `yaml:"server"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Cipher string `yaml:"cipher"` + UUID string `yaml:"uuid"` + AlterID int `yaml:"alterId"` + Network string `yaml:"network"` + TLS bool `yaml:"tls"` + ServerName string `yaml:"servername"` + SNI string `yaml:"sni"` + Flow string `yaml:"flow"` + SkipCertVerify bool `yaml:"skip-cert-verify"` + ClientFingerprint string `yaml:"client-fingerprint"` + Plugin string `yaml:"plugin"` + WSOptions clashWebSocketOption `yaml:"ws-opts"` + RealityOptions clashRealityOptions `yaml:"reality-opts"` +} + +type clashWebSocketOption struct { + Path string `yaml:"path"` + Headers map[string]string `yaml:"headers"` +} + +type clashRealityOptions struct { + PublicKey string `yaml:"public-key"` + ShortID string `yaml:"short-id"` +} + +type clashVMessShare struct { + Version string `json:"v"` + Address string `json:"add"` + Port string `json:"port"` + UUID string `json:"id"` + AlterID string `json:"aid"` + Cipher string `json:"scy"` + Network string `json:"net"` + TLS string `json:"tls,omitempty"` + ServerName string `json:"sni,omitempty"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + AllowInsecure bool `json:"allowInsecure,omitempty"` +} + +func parseClashSubscription(value string) ([]subscriptionEntry, int, bool) { + var document clashSubscription + if err := yaml.Unmarshal([]byte(strings.TrimPrefix(value, "\ufeff")), &document); err != nil || document.Proxies == nil { + return nil, 0, false + } + if len(document.Proxies) > maxSubscriptionEntries { + return nil, len(document.Proxies), true + } + lines := make([]string, 0, len(document.Proxies)) + skipped := 0 + for _, proxy := range document.Proxies { + line, err := clashProxyURL(proxy) + if err != nil { + skipped++ + continue + } + lines = append(lines, line) + } + entries, lineSkipped := parseProxyLines(strings.Join(lines, "\n")) + return entries, skipped + lineSkipped, true +} + +func clashProxyURL(proxy clashProxy) (string, error) { + server, err := clashProxyServer(proxy.Server, proxy.Port) + if err != nil { + return "", err + } + proxyType := strings.ToLower(strings.TrimSpace(proxy.Type)) + switch proxyType { + case "http": + scheme := "http" + if proxy.TLS { + scheme = "https" + } + return clashStandardProxyURL(scheme, server, proxy.Username, proxy.Password), nil + case "socks5": + if proxy.TLS { + return "", errors.New("暂不支持 TLS SOCKS5") + } + return clashStandardProxyURL("socks5", server, proxy.Username, proxy.Password), nil + case "ss": + if strings.TrimSpace(proxy.Plugin) != "" { + return "", errors.New("暂不支持 Shadowsocks plugin") + } + method := strings.ToLower(strings.TrimSpace(proxy.Cipher)) + if method == "" || proxy.Password == "" { + return "", errors.New("Shadowsocks cipher/password 不能为空") + } + credential := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + proxy.Password)) + return "ss://" + credential + "@" + server, nil + case "trojan": + if proxy.Password == "" { + return "", errors.New("Trojan password 不能为空") + } + query, err := clashTunnelQuery(proxy, "tls") + if err != nil { + return "", err + } + return (&url.URL{Scheme: "trojan", User: url.User(proxy.Password), Host: server, RawQuery: query.Encode()}).String(), nil + case "vless": + if proxy.UUID == "" { + return "", errors.New("VLESS UUID 不能为空") + } + security := "none" + if proxy.RealityOptions.PublicKey != "" || proxy.RealityOptions.ShortID != "" { + security = "reality" + } else if proxy.TLS { + security = "tls" + } + query, err := clashTunnelQuery(proxy, security) + if err != nil { + return "", err + } + query.Set("encryption", "none") + if flow := strings.TrimSpace(proxy.Flow); flow != "" { + query.Set("flow", flow) + } + if security == "reality" { + query.Set("pbk", proxy.RealityOptions.PublicKey) + query.Set("sid", proxy.RealityOptions.ShortID) + query.Set("fp", firstNonEmptySubscription(proxy.ClientFingerprint, "chrome")) + } + return (&url.URL{Scheme: "vless", User: url.User(proxy.UUID), Host: server, RawQuery: query.Encode()}).String(), nil + case "vmess": + return clashVMessURL(proxy) + default: + return "", fmt.Errorf("暂不支持 Clash 代理类型 %q", proxyType) + } +} + +func clashProxyServer(host string, port int) (string, error) { + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" || port < 1 || port > 65535 { + return "", errors.New("Clash 代理服务器地址无效") + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil +} + +func clashStandardProxyURL(scheme, server, username, password string) string { + value := &url.URL{Scheme: scheme, Host: server} + if username != "" || password != "" { + value.User = url.UserPassword(username, password) + } + return value.String() +} + +func clashTunnelQuery(proxy clashProxy, security string) (url.Values, error) { + transport := strings.ToLower(strings.TrimSpace(proxy.Network)) + if transport == "" { + transport = "tcp" + } + if transport == "websocket" { + transport = "ws" + } + query := make(url.Values) + query.Set("type", transport) + query.Set("security", security) + query.Set("sni", firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server)) + if proxy.SkipCertVerify { + query.Set("allowInsecure", "1") + } + if transport == "ws" { + query.Set("path", firstNonEmptySubscription(proxy.WSOptions.Path, "/")) + query.Set("host", firstNonEmptySubscription(proxy.WSOptions.Headers["Host"], proxy.WSOptions.Headers["host"], proxy.ServerName, proxy.SNI, proxy.Server)) + } + return query, nil +} + +func clashVMessURL(proxy clashProxy) (string, error) { + if proxy.UUID == "" { + return "", errors.New("VMess UUID 不能为空") + } + network := strings.ToLower(strings.TrimSpace(proxy.Network)) + if network == "" { + network = "tcp" + } + if network == "websocket" { + network = "ws" + } + share := clashVMessShare{ + Version: "2", Address: proxy.Server, Port: strconv.Itoa(proxy.Port), UUID: proxy.UUID, + AlterID: strconv.Itoa(proxy.AlterID), Cipher: firstNonEmptySubscription(proxy.Cipher, "auto"), Network: network, + ServerName: firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server), AllowInsecure: proxy.SkipCertVerify, + } + if proxy.TLS { + share.TLS = "tls" + } + if network == "ws" { + share.Path = firstNonEmptySubscription(proxy.WSOptions.Path, "/") + share.Host = firstNonEmptySubscription(proxy.WSOptions.Headers["Host"], proxy.WSOptions.Headers["host"], proxy.ServerName, proxy.SNI, proxy.Server) + } + encoded, err := json.Marshal(share) + if err != nil { + return "", err + } + return "vmess://" + base64.RawStdEncoding.EncodeToString(encoded), nil +} + +func firstNonEmptySubscription(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/application/egress/subscription_test.go b/backend/internal/application/egress/subscription_test.go index d7bb2cb4e..5986f5d98 100644 --- a/backend/internal/application/egress/subscription_test.go +++ b/backend/internal/application/egress/subscription_test.go @@ -15,6 +15,7 @@ import ( domain "github.com/chenyme/grok2api/backend/internal/domain/egress" "github.com/chenyme/grok2api/backend/internal/infra/security" + "github.com/chenyme/grok2api/backend/internal/pkg/tunnelproxy" ) type subscriptionSyncRepositoryStub struct { @@ -100,6 +101,165 @@ func TestParseProxySubscriptionImportsSupportedTunnelSchemes(t *testing.T) { } } +func TestParseProxySubscriptionAcceptsClashYAML(t *testing.T) { + content := ` +proxies: + - name: http + type: http + server: http.example + port: 8080 + username: user + password: pass + - name: socks + type: socks5 + server: socks.example + port: 1080 + - name: trojan + type: trojan + server: trojan.example + port: 443 + password: secret + network: ws + sni: edge.example + ws-opts: + path: /ws + headers: + Host: edge.example + - name: reality + type: vless + server: reality.example + port: 443 + uuid: 123e4567-e89b-12d3-a456-426614174000 + network: tcp + tls: true + servername: edge.example + flow: xtls-rprx-vision + client-fingerprint: chrome + reality-opts: + public-key: SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8 + short-id: 0123456789abcdef + - name: shadowsocks + type: ss + server: ss.example + port: 8388 + cipher: aes-128-gcm + password: secret + - name: vmess + type: vmess + server: vmess.example + port: 443 + uuid: 123e4567-e89b-12d3-a456-426614174000 + alterId: 0 + cipher: auto + network: ws + tls: true + servername: edge.example + ws-opts: + path: /vmess + headers: + Host: edge.example + - name: ignored-hysteria + type: hysteria2 + server: hy.example + port: 443 + password: secret + - name: ignored-tuic + type: tuic + server: tuic.example + port: 443 + password: secret +proxy-groups: + - name: auto + type: select + proxies: [http, socks] +` + entries, skipped, err := parseProxySubscription(content) + if err != nil { + t.Fatal(err) + } + if len(entries) != 6 || skipped != 2 { + t.Fatalf("Clash entries=%d skipped=%d values=%#v", len(entries), skipped, entries) + } + var realityConfig tunnelproxy.Config + for _, entry := range entries { + if strings.HasPrefix(entry.ProxyURL, "vless://") { + realityConfig, err = tunnelproxy.Parse(entry.ProxyURL) + if err != nil { + t.Fatal(err) + } + } + } + if realityConfig.Security != "reality" || realityConfig.Flow != "xtls-rprx-vision" || realityConfig.RealityPublicKey == "" || realityConfig.RealityShortID != "0123456789abcdef" { + t.Fatalf("Clash Reality config = %#v", realityConfig) + } +} + +func TestFetchProxySubscriptionUsesClashUserAgent(t *testing.T) { + var userAgent string + proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + userAgent = request.Header.Get("User-Agent") + _, _ = writer.Write([]byte("http://proxy.example:8080\n")) + })) + defer proxy.Close() + + body, err := fetchProxySubscription(context.Background(), "http://1.1.1.1/subscription", proxy.URL) + if err != nil { + t.Fatal(err) + } + if userAgent != "Clash.Meta" || !strings.Contains(string(body), "proxy.example") { + t.Fatalf("User-Agent=%q body=%q", userAgent, body) + } +} + +func TestClashRealityPreservesSupportedClientFingerprints(t *testing.T) { + content := ` +proxies: + - type: vless + server: chrome.example + port: 443 + uuid: 123e4567-e89b-12d3-a456-426614174000 + flow: xtls-rprx-vision + client-fingerprint: chrome + reality-opts: &reality + public-key: SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8 + short-id: 0123456789abcdef + - type: vless + server: edge.example + port: 443 + uuid: 123e4567-e89b-12d3-a456-426614174000 + flow: xtls-rprx-vision + client-fingerprint: edge + reality-opts: *reality + - type: vless + server: safari.example + port: 443 + uuid: 123e4567-e89b-12d3-a456-426614174000 + flow: xtls-rprx-vision + client-fingerprint: safari + reality-opts: *reality +` + entries, skipped, err := parseProxySubscription(content) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 || skipped != 0 { + t.Fatalf("entries=%d skipped=%d", len(entries), skipped) + } + fingerprints := make(map[string]bool) + for _, entry := range entries { + config, parseErr := tunnelproxy.Parse(entry.ProxyURL) + if parseErr != nil { + t.Fatal(parseErr) + } + fingerprints[config.ClientFingerprint] = true + } + for _, fingerprint := range []string{"chrome", "edge", "safari"} { + if !fingerprints[fingerprint] { + t.Fatalf("missing client fingerprint %q", fingerprint) + } + } +} + func TestIsPublicAddressRejectsNonPublicRanges(t *testing.T) { for _, raw := range []string{ "0.0.0.1", "10.0.0.1", "100.64.0.1", "127.0.0.1", "169.254.10.1", From 7521223143b38e60db1414602b294f4f9df40080 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:36:37 +0800 Subject: [PATCH 3/3] fix: harden Clash parsing and preserve tunnel ALPN --- .../application/egress/subscription_clash.go | 74 +++++++++++++++++-- .../application/egress/subscription_test.go | 44 +++++++++-- backend/internal/pkg/tunnelproxy/reality.go | 4 +- .../internal/pkg/tunnelproxy/tunnelproxy.go | 34 ++++++++- .../pkg/tunnelproxy/tunnelproxy_test.go | 36 +++++++++ 5 files changed, 175 insertions(+), 17 deletions(-) diff --git a/backend/internal/application/egress/subscription_clash.go b/backend/internal/application/egress/subscription_clash.go index 62d902f61..56c15bcde 100644 --- a/backend/internal/application/egress/subscription_clash.go +++ b/backend/internal/application/egress/subscription_clash.go @@ -14,23 +14,24 @@ import ( ) type clashSubscription struct { - Proxies []clashProxy `yaml:"proxies"` + Proxies []yaml.Node `yaml:"proxies"` } type clashProxy struct { Type string `yaml:"type"` Server string `yaml:"server"` - Port int `yaml:"port"` + Port clashInteger `yaml:"port"` Username string `yaml:"username"` Password string `yaml:"password"` Cipher string `yaml:"cipher"` UUID string `yaml:"uuid"` - AlterID int `yaml:"alterId"` + AlterID clashInteger `yaml:"alterId"` Network string `yaml:"network"` TLS bool `yaml:"tls"` ServerName string `yaml:"servername"` SNI string `yaml:"sni"` Flow string `yaml:"flow"` + ALPN clashStringList `yaml:"alpn"` SkipCertVerify bool `yaml:"skip-cert-verify"` ClientFingerprint string `yaml:"client-fingerprint"` Plugin string `yaml:"plugin"` @@ -48,6 +49,53 @@ type clashRealityOptions struct { ShortID string `yaml:"short-id"` } +type clashInteger int + +func (value *clashInteger) UnmarshalYAML(node *yaml.Node) error { + if node == nil || node.Kind != yaml.ScalarNode { + return errors.New("Clash 整数字段格式无效") + } + parsed, err := strconv.ParseInt(strings.TrimSpace(node.Value), 10, 32) + if err != nil { + return errors.New("Clash 整数字段格式无效") + } + *value = clashInteger(parsed) + return nil +} + +type clashStringList []string + +func (value *clashStringList) UnmarshalYAML(node *yaml.Node) error { + if node == nil { + return errors.New("Clash 字符串列表格式无效") + } + if node.Kind == yaml.AliasNode { + if node.Alias == nil { + return errors.New("Clash 字符串列表格式无效") + } + node = node.Alias + } + var items []string + switch node.Kind { + case yaml.ScalarNode: + items = strings.Split(node.Value, ",") + case yaml.SequenceNode: + if err := node.Decode(&items); err != nil { + return errors.New("Clash 字符串列表格式无效") + } + default: + return errors.New("Clash 字符串列表格式无效") + } + normalized := make([]string, 0, len(items)) + for _, item := range items { + if item = strings.TrimSpace(item); item != "" { + normalized = append(normalized, item) + } + } + *value = normalized + return nil +} + type clashVMessShare struct { Version string `json:"v"` Address string `json:"add"` @@ -58,6 +106,7 @@ type clashVMessShare struct { Network string `json:"net"` TLS string `json:"tls,omitempty"` ServerName string `json:"sni,omitempty"` + ALPN string `json:"alpn,omitempty"` Host string `json:"host,omitempty"` Path string `json:"path,omitempty"` AllowInsecure bool `json:"allowInsecure,omitempty"` @@ -73,7 +122,12 @@ func parseClashSubscription(value string) ([]subscriptionEntry, int, bool) { } lines := make([]string, 0, len(document.Proxies)) skipped := 0 - for _, proxy := range document.Proxies { + for index := range document.Proxies { + var proxy clashProxy + if err := document.Proxies[index].Decode(&proxy); err != nil { + skipped++ + continue + } line, err := clashProxyURL(proxy) if err != nil { skipped++ @@ -86,7 +140,7 @@ func parseClashSubscription(value string) ([]subscriptionEntry, int, bool) { } func clashProxyURL(proxy clashProxy) (string, error) { - server, err := clashProxyServer(proxy.Server, proxy.Port) + server, err := clashProxyServer(proxy.Server, int(proxy.Port)) if err != nil { return "", err } @@ -184,6 +238,9 @@ func clashTunnelQuery(proxy clashProxy, security string) (url.Values, error) { if proxy.SkipCertVerify { query.Set("allowInsecure", "1") } + if len(proxy.ALPN) != 0 { + query.Set("alpn", strings.Join(proxy.ALPN, ",")) + } if transport == "ws" { query.Set("path", firstNonEmptySubscription(proxy.WSOptions.Path, "/")) query.Set("host", firstNonEmptySubscription(proxy.WSOptions.Headers["Host"], proxy.WSOptions.Headers["host"], proxy.ServerName, proxy.SNI, proxy.Server)) @@ -203,9 +260,10 @@ func clashVMessURL(proxy clashProxy) (string, error) { network = "ws" } share := clashVMessShare{ - Version: "2", Address: proxy.Server, Port: strconv.Itoa(proxy.Port), UUID: proxy.UUID, - AlterID: strconv.Itoa(proxy.AlterID), Cipher: firstNonEmptySubscription(proxy.Cipher, "auto"), Network: network, - ServerName: firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server), AllowInsecure: proxy.SkipCertVerify, + Version: "2", Address: proxy.Server, Port: strconv.Itoa(int(proxy.Port)), UUID: proxy.UUID, + AlterID: strconv.Itoa(int(proxy.AlterID)), Cipher: firstNonEmptySubscription(proxy.Cipher, "auto"), Network: network, + ServerName: firstNonEmptySubscription(proxy.ServerName, proxy.SNI, proxy.Server), ALPN: strings.Join(proxy.ALPN, ","), + AllowInsecure: proxy.SkipCertVerify, } if proxy.TLS { share.TLS = "tls" diff --git a/backend/internal/application/egress/subscription_test.go b/backend/internal/application/egress/subscription_test.go index 5986f5d98..2536b967f 100644 --- a/backend/internal/application/egress/subscription_test.go +++ b/backend/internal/application/egress/subscription_test.go @@ -135,6 +135,7 @@ proxies: servername: edge.example flow: xtls-rprx-vision client-fingerprint: chrome + alpn: [h2, http/1.1] reality-opts: public-key: SOW7P-17ibm_-kz-QUQwGGyitSbsa5wOmRGAigGvDH8 short-id: 0123456789abcdef @@ -154,6 +155,7 @@ proxies: network: ws tls: true servername: edge.example + alpn: [h2, http/1.1] ws-opts: path: /vmess headers: @@ -181,17 +183,47 @@ proxy-groups: t.Fatalf("Clash entries=%d skipped=%d values=%#v", len(entries), skipped, entries) } var realityConfig tunnelproxy.Config + var vmessConfig tunnelproxy.Config for _, entry := range entries { - if strings.HasPrefix(entry.ProxyURL, "vless://") { - realityConfig, err = tunnelproxy.Parse(entry.ProxyURL) - if err != nil { - t.Fatal(err) - } + if !strings.HasPrefix(entry.ProxyURL, "vless://") && !strings.HasPrefix(entry.ProxyURL, "vmess://") { + continue + } + config, parseErr := tunnelproxy.Parse(entry.ProxyURL) + if parseErr != nil { + t.Fatal(parseErr) + } + switch config.Scheme { + case "vless": + realityConfig = config + case "vmess": + vmessConfig = config } } - if realityConfig.Security != "reality" || realityConfig.Flow != "xtls-rprx-vision" || realityConfig.RealityPublicKey == "" || realityConfig.RealityShortID != "0123456789abcdef" { + if realityConfig.Security != "reality" || realityConfig.Flow != "xtls-rprx-vision" || realityConfig.RealityPublicKey == "" || realityConfig.RealityShortID != "0123456789abcdef" || strings.Join(realityConfig.ALPN, ",") != "h2,http/1.1" { t.Fatalf("Clash Reality config = %#v", realityConfig) } + if strings.Join(vmessConfig.ALPN, ",") != "h2,http/1.1" { + t.Fatalf("Clash VMess config = %#v", vmessConfig) + } +} + +func TestParseProxySubscriptionSkipsMalformedClashEntriesIndividually(t *testing.T) { + content := ` +proxies: + - type: http + server: valid.example + port: "8080" + - type: hysteria2 + server: ignored.example + port: invalid +` + entries, skipped, err := parseProxySubscription(content) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || skipped != 1 || entries[0].ProxyURL != "http://valid.example:8080" { + t.Fatalf("entries=%#v skipped=%d", entries, skipped) + } } func TestFetchProxySubscriptionUsesClashUserAgent(t *testing.T) { diff --git a/backend/internal/pkg/tunnelproxy/reality.go b/backend/internal/pkg/tunnelproxy/reality.go index e2358ae7a..fe697be38 100644 --- a/backend/internal/pkg/tunnelproxy/reality.go +++ b/backend/internal/pkg/tunnelproxy/reality.go @@ -86,7 +86,9 @@ func (p *realityProxy) Conn(ctx context.Context, address netapi.Address) (net.Co if err != nil { return nil, err } - secure, err := p.handshake(ctx, connection) + handshakeCtx, cancel := newTunnelHandshakeContext(ctx) + defer cancel() + secure, err := p.handshake(handshakeCtx, connection) if err != nil { _ = connection.Close() return nil, fmt.Errorf("Reality 握手: %w", err) diff --git a/backend/internal/pkg/tunnelproxy/tunnelproxy.go b/backend/internal/pkg/tunnelproxy/tunnelproxy.go index de0ac3409..26e4b11ff 100644 --- a/backend/internal/pkg/tunnelproxy/tunnelproxy.go +++ b/backend/internal/pkg/tunnelproxy/tunnelproxy.go @@ -344,6 +344,7 @@ type vmessShare struct { Network string `json:"net"` TLS string `json:"tls,omitempty"` ServerName string `json:"sni,omitempty"` + ALPN string `json:"alpn,omitempty"` Host string `json:"host,omitempty"` Path string `json:"path,omitempty"` AllowInsecure bool `json:"allowInsecure,omitempty"` @@ -399,6 +400,10 @@ func parseVMess(value string) (Config, error) { } tlsEnabled := tlsMode == "tls" serverName := firstNonEmpty(jsonString(raw, "sni"), address) + alpn, err := jsonStringList(raw, "alpn") + if err != nil { + return Config{}, err + } host := firstNonEmpty(jsonString(raw, "host"), serverName) if err := validateWebSocketHost(host); transport == "ws" && err != nil { return Config{}, err @@ -418,7 +423,7 @@ func parseVMess(value string) (Config, error) { } share := vmessShare{ Version: "2", Address: address, Port: port, UUID: userID, AlterID: strconv.Itoa(alterID), Cipher: cipher, - Network: transport, ServerName: serverName, AllowInsecure: insecure, + Network: transport, ServerName: serverName, ALPN: strings.Join(alpn, ","), AllowInsecure: insecure, } if transport == "ws" { share.Host = host @@ -433,7 +438,7 @@ func parseVMess(value string) (Config, error) { } config := Config{ Scheme: "vmess", Server: server, Credential: userID, AlterID: alterID, Cipher: cipher, - Transport: transport, TLS: tlsEnabled, ServerName: serverName, Insecure: insecure, + Transport: transport, TLS: tlsEnabled, ServerName: serverName, Insecure: insecure, ALPN: alpn, WebSocketHost: host, WebSocketPath: path, CanonicalProxyURL: "vmess://" + base64.RawStdEncoding.EncodeToString(canonicalJSON), } @@ -844,3 +849,28 @@ func jsonString(value map[string]any, name string) string { return "" } } + +func jsonStringList(value map[string]any, name string) ([]string, error) { + raw, ok := value[name] + if !ok || raw == nil { + return nil, nil + } + if text, ok := raw.(string); ok { + return splitList(text), nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s 必须是字符串或字符串数组", name) + } + result := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s 必须是字符串或字符串数组", name) + } + if text = strings.TrimSpace(text); text != "" { + result = append(result, text) + } + } + return result, nil +} diff --git a/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go b/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go index c5755fd80..c205cc6e6 100644 --- a/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go +++ b/backend/internal/pkg/tunnelproxy/tunnelproxy_test.go @@ -196,6 +196,38 @@ func TestNormalizeEquivalentShareLinksHaveOneIdentity(t *testing.T) { } } +func TestNormalizeVMessPreservesALPN(t *testing.T) { + stringURL := vmessTestURL(t, map[string]any{ + "v": "2", "add": "proxy.example", "port": "443", "id": testUUID, + "aid": "0", "scy": "auto", "net": "tcp", "tls": "tls", "sni": "edge.example", + "alpn": "h2, http/1.1", + }) + arrayURL := vmessTestURL(t, map[string]any{ + "v": "2", "add": "proxy.example", "port": "443", "id": testUUID, + "aid": "0", "scy": "auto", "net": "tcp", "tls": "tls", "sni": "edge.example", + "alpn": []string{"h2", "http/1.1"}, + }) + stringNormalized, err := Normalize(stringURL) + if err != nil { + t.Fatal(err) + } + arrayNormalized, err := Normalize(arrayURL) + if err != nil { + t.Fatal(err) + } + if stringNormalized != arrayNormalized { + t.Fatalf("equivalent VMess ALPN forms have different identities: %q != %q", stringNormalized, arrayNormalized) + } + config, err := Parse(stringNormalized) + if err != nil { + t.Fatal(err) + } + wanted := "h2,http/1.1" + if strings.Join(config.ALPN, ",") != wanted || strings.Join((&tlsProxy{config: config}).tlsConfig().NextProtos, ",") != wanted { + t.Fatalf("VMess ALPN was not preserved in TLS config: %#v", config) + } +} + func TestOwnedVLESSProxyClosesConnectionWhenInitialWriteFails(t *testing.T) { client, server := net.Pipe() if err := server.Close(); err != nil { @@ -290,6 +322,10 @@ func TestParseRejectsUnsupportedOrMalformedLinks(t *testing.T) { "ss://" + base64.RawURLEncoding.EncodeToString([]byte("rc4-md5:secret")) + "@proxy.example:8388", "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:secret")) + "@proxy.example:8388/unexpected", "vmess://not-base64", + vmessTestURL(t, map[string]any{ + "v": "2", "add": "proxy.example", "port": "443", "id": testUUID, + "aid": "0", "scy": "auto", "net": "tcp", "tls": "tls", "alpn": []any{"h2", 1}, + }), } { if _, err := Normalize(raw); err == nil { t.Fatalf("invalid tunnel accepted: %q", raw)