From cc3303dd8cdd3959faada997f2c2aeb31e894499 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 7 Jul 2026 12:44:18 +0200 Subject: [PATCH] fix(sub): carry a host's Final Mask into raw share links A Host's Final Mask was merged into the JSON and Clash subscription outputs via applyHostStreamOverrides, but the raw link builders compute the fm param once from the inbound's own streamSettings.finalmask before the per-host fan-out, and the endpoint override path never read the host's mask. A Final Mask configured only on a host was silently dropped from vless/trojan/ss/vmess share links while an inbound-level mask worked everywhere. Merge the host mask into the fm param per endpoint with the same additive semantics as the JSON path (host tcp/udp masks appended to the inbound's, quicParams only when the inbound has none), for both the URL-param and the VMess object link forms. Closes #5831 --- internal/sub/endpoint.go | 2 ++ internal/sub/endpoint_test.go | 44 +++++++++++++++++++++++++++++++++++ internal/sub/host_sub.go | 38 ++++++++++++++++++++++++++++++ internal/sub/host_sub_test.go | 23 ++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index 938b9ba17..cfef3a665 100644 --- a/internal/sub/endpoint.go +++ b/internal/sub/endpoint.go @@ -90,6 +90,7 @@ func (s *SubService) buildEndpointLinks( applyEndpointTLSParams(e, nextParams, securityToApply) applyEndpointRealityParams(e, nextParams, securityToApply) applyEndpointHostPath(e, nextParams) + applyEndpointFinalMask(e, nextParams) applyEndpointAllowInsecure(e, nextParams, securityToApply) links = append(links, buildLinkWithParamsAndSecurity( makeLink(e), @@ -119,6 +120,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st } applyEndpointTLSObj(e, newObj, securityToApply) applyEndpointHostPathObj(e, newObj) + applyEndpointFinalMaskObj(e, newObj) if index > 0 { links.WriteString("\n") } diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index af78c1913..8d5f763f8 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -1,7 +1,11 @@ package sub import ( + "encoding/base64" + "encoding/json" "fmt" + "net/url" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -111,3 +115,43 @@ func TestBuildEndpointVmessLinks(t *testing.T) { t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want) } } + +// N5 — a host's Final Mask is appended to the inbound's own fm param (#5831). +func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) { + s := &SubService{} + in := &model.Inbound{Remark: "ib"} + params := map[string]string{"type": "tcp", "security": "tls", "fm": `{"tcp":[{"type":"sudoku"}]}`} + eps := []ShareEndpoint{ + externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"tcp":[{"type":"fragment"}]}`}), + } + got := s.buildEndpointLinks(eps, params, "tls", + func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) }, + func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") }, + ) + wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`) + if !strings.Contains(got, wantFm) { + t.Fatalf("host finalMask not merged into the fm param.\n got: %q\nwant substring: %q", got, wantFm) + } +} + +// N6 — same for the VMess object form: the host mask lands in obj["fm"]. +func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) { + s := &SubService{} + in := &model.Inbound{Remark: "ib"} + baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "tls"} + eps := []ShareEndpoint{ + externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"udp":[{"type":"salamander"}]}`}), + } + got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp") + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://")) + if err != nil { + t.Fatalf("decode vmess link: %v", err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal vmess obj: %v", err) + } + if fm, _ := obj["fm"].(string); fm != `{"udp":[{"type":"salamander"}]}` { + t.Fatalf("vmess fm = %q, want the host mask", obj["fm"]) + } +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 175de5e01..16ca6647d 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -305,6 +305,44 @@ func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, secur } } +// applyEndpointFinalMask merges a host's Final Mask into the raw link's fm +// param, mirroring the applyHostStreamOverrides merge on the JSON/Clash path. +func applyEndpointFinalMask(e ShareEndpoint, params map[string]string) { + if merged, ok := endpointFinalMask(e, params["fm"]); ok { + params["fm"] = merged + } +} + +// applyEndpointFinalMaskObj is applyEndpointFinalMask for the VMess object form. +func applyEndpointFinalMaskObj(e ShareEndpoint, obj map[string]any) { + baseFm, _ := obj["fm"].(string) + if merged, ok := endpointFinalMask(e, baseFm); ok { + obj["fm"] = merged + } +} + +func endpointFinalMask(e ShareEndpoint, baseFm string) (string, bool) { + if e.ep == nil { + return "", false + } + fm, ok := e.ep["finalMask"].(string) + if !ok || fm == "" { + return "", false + } + var masks map[string]any + if json.Unmarshal([]byte(fm), &masks) != nil || len(masks) == 0 { + return "", false + } + var base any + if baseFm != "" { + var baseMap map[string]any + if json.Unmarshal([]byte(baseFm), &baseMap) == nil { + base = baseMap + } + } + return marshalFinalMask(mergeFinalMask(base, masks)) +} + // applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form. func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) { if e.ep == nil { diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 09213d1bb..9719f7d83 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -2,6 +2,7 @@ package sub import ( "fmt" + "net/url" "path/filepath" "strings" "testing" @@ -270,6 +271,28 @@ func TestSub_HostAllowInsecure(t *testing.T) { } } +// A host's Final Mask reaches the raw share link as the fm param, merged with +// any inbound-level mask (#5831). +func TestSub_HostFinalMask_RawLink(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "fmh", 4455, 1, + `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls", + FinalMask: `{"tcp":[{"type":"fragment"}]}`, + }) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + joined := strings.Join(links, "\n") + wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`) + if !strings.Contains(joined, wantFm) { + t.Fatalf("raw link should merge the host Final Mask into fm.\n got: %s\nwant substring: %s", joined, wantFm) + } +} + // A host's sockoptParams is injected into the JSON output stream (sockopt is // stripped from the base stream, re-added per host). func TestSub_HostSockoptJSON(t *testing.T) {