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) {