diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 701c89dc0..5bed00b15 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -144,9 +144,36 @@ function hasShareableFinalMaskValue(value: unknown): boolean { return true; } +function withLegacyFragmentRanges(finalmask: FinalMaskStreamSettings): FinalMaskStreamSettings { + // Stored rows reach here unparsed: dropEmptyFinalMask deletes an empty `tcp` on save. + if (!Array.isArray(finalmask.tcp)) return finalmask; + let changed = false; + const tcp = finalmask.tcp.map((mask) => { + if (mask.type !== 'fragment' || !mask.settings) return mask; + + const settings = mask.settings; + const legacy: Record = {}; + if (settings.length === undefined && Array.isArray(settings.lengths)) { + const length = settings.lengths.at(-1); + if (typeof length === 'string' && length.trim().length > 0) legacy.length = length; + } + if (settings.delay === undefined && Array.isArray(settings.delays)) { + const delay = settings.delays.at(-1); + if (typeof delay === 'string' && delay.trim().length > 0) legacy.delay = delay; + } + if (Object.keys(legacy).length === 0) return mask; + + changed = true; + return { ...mask, settings: { ...settings, ...legacy } }; + }); + + return changed ? { ...finalmask, tcp } : finalmask; +} + function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string { if (!finalmask) return ''; - return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : ''; + const shareable = withLegacyFragmentRanges(finalmask); + return hasShareableFinalMaskValue(shareable) ? JSON.stringify(shareable) : ''; } function applyFinalMaskToObj( diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index abda78ae8..90bed6bc1 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -19,9 +19,11 @@ import { preferPublicHost, resolveAddr, } from '@/lib/xray/inbound-link'; +import { type DbInboundLike, inboundFromDb } from '@/lib/xray/inbound-from-db'; import { InboundSchema } from '@/schemas/api/inbound'; import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard'; +import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask'; // reverse of inbound-link.ts's own toBase64Url, for asserting on the // decoded vpn:// payload without depending on that helper being exported. @@ -179,6 +181,129 @@ describe('genVlessLink vlessRoute', () => { }); }); +describe('genVlessLink TCP fragment finalmask compatibility', () => { + const [, raw] = fixturesForProtocol('vless')[0]; + const baseInbound = InboundSchema.parse(raw); + const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id; + + function linkFor(finalmask: FinalMaskStreamSettings): string { + if (!baseInbound.streamSettings) throw new Error('fixture needs stream settings'); + baseInbound.streamSettings.finalmask = finalmask; + + return genVlessLink({ + inbound: baseInbound, + address: 'example.test', + port: baseInbound.port, + clientId, + }); + } + + function finalmaskFrom(link: string): Record { + const encoded = new URL(link).searchParams.get('fm'); + if (!encoded) throw new Error('link needs an fm parameter'); + return JSON.parse(encoded) as Record; + } + + it('emits the final configured length and delay for legacy clients', () => { + const finalmask: FinalMaskStreamSettings = { + tcp: [ + { + type: 'fragment', + settings: { + packets: 'tlshello', + lengths: ['5-10', '10-15', '15-20', '20-25', '25-30'], + delays: ['10-20', '5-20', '5-25', '15-25', '10-30'], + maxSplit: '10-15', + }, + }, + ], + udp: [], + }; + const tcpMasks = finalmask.tcp; + const fragmentSettings = finalmask.tcp[0].settings; + const lengths = fragmentSettings?.lengths; + const delays = fragmentSettings?.delays; + const before = structuredClone(finalmask); + + const exported = finalmaskFrom(linkFor(finalmask)); + const fragment = (exported.tcp as Array<{ settings: Record }>)[0].settings; + + expect(fragment).toEqual({ + packets: 'tlshello', + lengths: ['5-10', '10-15', '15-20', '20-25', '25-30'], + delays: ['10-20', '5-20', '5-25', '15-25', '10-30'], + maxSplit: '10-15', + length: '25-30', + delay: '10-30', + }); + expect(finalmask).toEqual(before); + expect(finalmask.tcp).toBe(tcpMasks); + expect(finalmask.tcp[0].settings).toBe(fragmentSettings); + expect(fragmentSettings?.lengths).toBe(lengths); + expect(fragmentSettings?.delays).toBe(delays); + }); + + it('preserves explicit legacy fields and does not add them to other masks', () => { + const finalmask: FinalMaskStreamSettings = { + tcp: [ + { + type: 'fragment', + settings: { + length: '40-50', + delay: '3-4', + lengths: ['5-10', '25-30'], + delays: ['10-20', '10-30'], + }, + }, + { type: 'sudoku', settings: { lengths: ['5-10'], delays: ['10-20'] } }, + ], + udp: [{ type: 'noise', settings: { lengths: ['5-10'], delays: ['10-20'] } }], + }; + + const exported = finalmaskFrom(linkFor(finalmask)); + + expect(exported).toEqual(finalmask); + }); + + it('exports a stored UDP-only finalmask whose empty tcp list was dropped on save', () => { + const udpOnly = { udp: [{ type: 'salamander', settings: { password: 'p' } }] }; + const inbound = inboundFromDb({ + ...(raw as unknown as DbInboundLike), + streamSettings: { ...(raw.streamSettings as Record), finalmask: udpOnly }, + }); + + const link = genVlessLink({ inbound, address: 'example.test', port: inbound.port, clientId }); + + expect(finalmaskFrom(link)).toEqual(udpOnly); + }); + + it('does not create empty legacy values or search before a mixed-type last entry', () => { + const finalmask: FinalMaskStreamSettings = { + tcp: [ + { type: 'fragment', settings: { packets: 'tlshello', lengths: [], delays: [] } }, + { + type: 'fragment', + settings: { packets: 'tlshello', lengths: ['5-10', 25], delays: ['10-20', null] }, + }, + { type: 'fragment', settings: { lengths: [' '], delays: ['\t'] } }, + ], + udp: [], + }; + + const exported = finalmaskFrom(linkFor(finalmask)); + const [emptyRanges, mixedRanges, blankRanges] = exported.tcp as Array<{ + settings: Record; + }>; + + expect(emptyRanges.settings).not.toHaveProperty('length'); + expect(emptyRanges.settings).not.toHaveProperty('delay'); + expect(mixedRanges.settings).not.toHaveProperty('length'); + expect(mixedRanges.settings).not.toHaveProperty('delay'); + expect(blankRanges.settings).not.toHaveProperty('length'); + expect(blankRanges.settings).not.toHaveProperty('delay'); + }); +}); + describe('genTrojanLink', () => { const fixtures = fixturesForProtocol('trojan'); expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0); diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 5ae3ff9a2..b159af6ff 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -1,6 +1,7 @@ package sub import ( + "encoding/json" "fmt" "net/url" "path/filepath" @@ -308,19 +309,107 @@ 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"}]}}`) + finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}` seedHost(t, &model.Host{ InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls", - FinalMask: `{"tcp":[{"type":"fragment"}]}`, + FinalMask: finalMask, }) 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) + if len(links) == 0 { + t.Fatal("GetSubs returned no links") + } + link, err := url.Parse(strings.Split(links[0], "\n")[0]) + if err != nil { + t.Fatalf("parse raw link: %v", err) + } + var finalmask map[string]any + if err := json.Unmarshal([]byte(link.Query().Get("fm")), &finalmask); err != nil { + t.Fatalf("unmarshal fm query param: %v", err) + } + tcp, _ := finalmask["tcp"].([]any) + if len(tcp) != 2 { + t.Fatalf("tcp mask count = %d, want existing + host mask: %#v", len(tcp), finalmask) + } + fragment, _ := tcp[1].(map[string]any) + settings, _ := fragment["settings"].(map[string]any) + if settings["length"] != "25-30" || settings["delay"] != "10-30" { + t.Fatalf("legacy ranges = (%v, %v), want last per-segment values", settings["length"], settings["delay"]) + } + if len(settings["lengths"].([]any)) != 5 || len(settings["delays"].([]any)) != 5 { + t.Fatalf("per-segment ranges changed: %#v", settings) + } +} + +func TestSub_HostFinalMaskJSONAddsLegacyFragmentRanges(t *testing.T) { + seedSubDB(t) + baseStream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku","settings":{"password":"p"}}]}}` + ib := seedSubInbound(t, "s1", "fmj", 4456, 1, baseStream) + finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}` + host := seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm-json.cdn.com", Port: 8444, Security: "tls", + FinalMask: finalMask, + }) + + globalFinalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["31-40"],"delays":[]}}]}` + out, _, err := NewSubJsonService("", "", globalFinalMask, "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var config map[string]any + if err := json.Unmarshal([]byte(out), &config); err != nil { + t.Fatalf("unmarshal JSON subscription: %v", err) + } + outbounds, _ := config["outbounds"].([]any) + if len(outbounds) == 0 { + t.Fatalf("JSON subscription has no outbounds: %s", out) + } + outbound, _ := outbounds[0].(map[string]any) + stream, _ := outbound["streamSettings"].(map[string]any) + finalmask, _ := stream["finalmask"].(map[string]any) + tcp, _ := finalmask["tcp"].([]any) + if len(tcp) != 3 { + t.Fatalf("tcp mask count = %d, want base + global + host masks: %#v", len(tcp), finalmask) + } + globalFragment, _ := tcp[1].(map[string]any) + globalSettings, _ := globalFragment["settings"].(map[string]any) + if globalSettings["length"] != "31-40" { + t.Fatalf("global legacy length = %v, want 31-40", globalSettings["length"]) + } + if _, exists := globalSettings["delay"]; exists { + t.Fatalf("empty global delays must not emit a fallback: %#v", globalSettings) + } + fragment, _ := tcp[2].(map[string]any) + settings, _ := fragment["settings"].(map[string]any) + if settings["length"] != "25-30" || settings["delay"] != "10-30" { + t.Fatalf("legacy ranges = (%v, %v), want the final per-segment ranges", settings["length"], settings["delay"]) + } + if got := settings["lengths"].([]any); len(got) != 5 || got[4] != "25-30" { + t.Fatalf("per-segment lengths changed: %#v", settings["lengths"]) + } + if got := settings["delays"].([]any); len(got) != 5 || got[4] != "10-30" { + t.Fatalf("per-segment delays changed: %#v", settings["delays"]) + } + if got := settings["maxSplit"]; got != "10-15" { + t.Fatalf("maxSplit = %v, want 10-15", got) + } + + var storedHost model.Host + if err := database.GetDB().First(&storedHost, host.Id).Error; err != nil { + t.Fatalf("reload host: %v", err) + } + if storedHost.FinalMask != finalMask { + t.Fatalf("stored host FinalMask changed: %s", storedHost.FinalMask) + } + var storedInbound model.Inbound + if err := database.GetDB().First(&storedInbound, ib.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if storedInbound.StreamSettings != baseStream { + t.Fatalf("stored inbound StreamSettings changed: %s", storedInbound.StreamSettings) } } diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 138cc0051..91f9d178e 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -636,6 +636,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c applyExternalProxyTLSToStream(extPrxy, newStream, security) } applyHostStreamOverrides(extPrxy, newStream) + if finalmask, ok := newStream["finalmask"].(map[string]any); ok { + newStream["finalmask"] = withLegacyFragmentRanges(finalmask) + } streamSettings, _ := json.MarshalIndent(newStream, "", " ") hostMux := hostMuxOverride(extPrxy) diff --git a/internal/sub/service.go b/internal/sub/service.go index d777f6986..d1f832ee6 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2844,7 +2844,7 @@ func applyFinalMaskObj(finalmask map[string]any, obj map[string]any) { } func marshalFinalMask(finalmask map[string]any) (string, bool) { - normalized := normalizeFinalMask(finalmask) + normalized := withLegacyFragmentRanges(normalizeFinalMask(finalmask)) if !hasFinalMaskContent(normalized) { return "", false } @@ -2855,6 +2855,71 @@ func marshalFinalMask(finalmask map[string]any) (string, bool) { return string(b), true } +// withLegacyFragmentRanges copies the last lengths/delays entry into the singular fields older +// cores require; newer xray-core prefers the arrays whenever they are non-empty. +func withLegacyFragmentRanges(finalmask map[string]any) map[string]any { + tcpMasks, ok := finalmask["tcp"].([]any) + if !ok { + return finalmask + } + + var result map[string]any + var resultMasks []any + for i, rawMask := range tcpMasks { + mask, ok := rawMask.(map[string]any) + if !ok || mask["type"] != "fragment" { + continue + } + settings, ok := mask["settings"].(map[string]any) + if !ok { + continue + } + + legacySettings := maps.Clone(settings) + changed := false + if _, exists := settings["length"]; !exists { + if value, ok := lastFragmentRange(settings["lengths"]); ok { + legacySettings["length"] = value + changed = true + } + } + if _, exists := settings["delay"]; !exists { + if value, ok := lastFragmentRange(settings["delays"]); ok { + legacySettings["delay"] = value + changed = true + } + } + if !changed { + continue + } + + if result == nil { + result = maps.Clone(finalmask) + resultMasks = slices.Clone(tcpMasks) + result["tcp"] = resultMasks + } + legacyMask := maps.Clone(mask) + legacyMask["settings"] = legacySettings + resultMasks[i] = legacyMask + } + if result == nil { + return finalmask + } + return result +} + +func lastFragmentRange(value any) (string, bool) { + ranges, _ := value.([]any) + if len(ranges) == 0 { + return "", false + } + rangeValue, ok := ranges[len(ranges)-1].(string) + if !ok || strings.TrimSpace(rangeValue) == "" { + return "", false + } + return rangeValue, true +} + func normalizeFinalMask(finalmask map[string]any) map[string]any { tcpMasks := normalizedFinalMaskTCPMasks(finalmask) udpMasks := normalizedFinalMaskUDPMasks(finalmask) diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index a69c6eec4..38e2f5545 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -3,6 +3,7 @@ package sub import ( "encoding/base64" "encoding/json" + "reflect" "strings" "testing" @@ -1055,6 +1056,102 @@ func TestMarshalFinalMask_WithContent(t *testing.T) { } } +func TestMarshalFinalMaskAddsLegacyFragmentRanges(t *testing.T) { + lengths := []any{"5-10", "10-15", "15-20", "20-25", "25-30"} + delays := []any{"10-20", "5-20", "5-25", "15-25", "10-30"} + screenshotSettings := map[string]any{ + "packets": "tlshello", + "lengths": lengths, + "delays": delays, + "maxSplit": "10-15", + } + explicitSettings := map[string]any{ + "length": "1-2", + "lengths": []any{"8-9"}, + "delay": "3-4", + "delays": []any{"6-7"}, + } + emptySettings := map[string]any{ + "lengths": []any{}, + "delays": []any{}, + } + legacyOnlySettings := map[string]any{"length": "40-50", "delay": "10-20"} + otherSettings := map[string]any{"lengths": []any{"30-40"}} + fm := map[string]any{ + "tcp": []any{ + map[string]any{"type": "fragment", "settings": screenshotSettings}, + map[string]any{"type": "fragment", "settings": explicitSettings}, + map[string]any{"type": "fragment", "settings": emptySettings}, + map[string]any{"type": "fragment", "settings": legacyOnlySettings}, + map[string]any{"type": "sudoku", "settings": otherSettings}, + }, + } + original, err := json.Marshal(fm) + if err != nil { + t.Fatalf("marshal input finalmask: %v", err) + } + + encoded, ok := marshalFinalMask(fm) + if !ok { + t.Fatal("expected finalmask with fragment masks to be marshaled") + } + var got map[string]any + if err := json.Unmarshal([]byte(encoded), &got); err != nil { + t.Fatalf("unmarshal marshaled finalmask: %v", err) + } + masks, _ := got["tcp"].([]any) + if len(masks) != 5 { + t.Fatalf("tcp mask count = %d, want 5", len(masks)) + } + settingsAt := func(index int) map[string]any { + t.Helper() + mask, _ := masks[index].(map[string]any) + settings, _ := mask["settings"].(map[string]any) + if settings == nil { + t.Fatalf("tcp[%d] settings missing: %#v", index, mask) + } + return settings + } + + gotScreenshot := settingsAt(0) + if gotScreenshot["length"] != "25-30" || gotScreenshot["delay"] != "10-30" { + t.Fatalf("legacy ranges = (%v, %v), want last array entries", gotScreenshot["length"], gotScreenshot["delay"]) + } + if !reflect.DeepEqual(gotScreenshot["lengths"], lengths) || !reflect.DeepEqual(gotScreenshot["delays"], delays) { + t.Fatalf("per-segment ranges changed: lengths=%#v delays=%#v", gotScreenshot["lengths"], gotScreenshot["delays"]) + } + if gotScreenshot["packets"] != "tlshello" || gotScreenshot["maxSplit"] != "10-15" { + t.Fatalf("other fragment settings changed: %#v", gotScreenshot) + } + + gotExplicit := settingsAt(1) + if gotExplicit["length"] != "1-2" || gotExplicit["delay"] != "3-4" { + t.Fatalf("explicit legacy ranges were overwritten: %#v", gotExplicit) + } + gotEmpty := settingsAt(2) + if _, exists := gotEmpty["length"]; exists { + t.Fatalf("empty lengths must not emit a fallback: %#v", gotEmpty) + } + if _, exists := gotEmpty["delay"]; exists { + t.Fatalf("empty delays must not emit a fallback: %#v", gotEmpty) + } + gotLegacyOnly := settingsAt(3) + if gotLegacyOnly["length"] != "40-50" || gotLegacyOnly["delay"] != "10-20" { + t.Fatalf("legacy-only ranges changed: %#v", gotLegacyOnly) + } + if _, exists := settingsAt(4)["length"]; exists { + t.Fatalf("non-fragment mask received a fallback: %#v", settingsAt(4)) + } + + after, err := json.Marshal(fm) + if err != nil { + t.Fatalf("marshal input finalmask after export: %v", err) + } + if string(after) != string(original) { + t.Fatalf("marshalFinalMask mutated its input:\nbefore: %s\nafter: %s", original, after) + } +} + func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) { fm := map[string]any{ "tcp": []any{