Procházet zdrojové kódy

Fix fragment exports for older Xray clients (#6702)

* Fix fragment exports for older Xray clients

* fix(link): tolerate a finalmask without tcp in panel share links

withLegacyFragmentRanges called finalmask.tcp.map unguarded, but stored
rows reach the link generator unparsed and dropEmptyFinalMask deletes an
empty tcp list on save. Any VMess/VLESS/Trojan/SS inbound with only UDP
masks or quicParams threw a TypeError in the QR, info and export-links
views. The Go counterpart already skipped a missing tcp.

Also trims the Go helper's comment to the two-line cap and drops a
[]string branch no JSON-decoded finalmask can reach.

---------

Co-authored-by: Artem K <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Artem K před 9 hodinami
rodič
revize
721de5adde

+ 28 - 1
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<string, unknown> = {};
+    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(

+ 125 - 0
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<string, unknown> {
+    const encoded = new URL(link).searchParams.get('fm');
+    if (!encoded) throw new Error('link needs an fm parameter');
+    return JSON.parse(encoded) as Record<string, unknown>;
+  }
+
+  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<string, unknown> }>)[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<string, unknown>), 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<string, unknown>;
+    }>;
+
+    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);

+ 94 - 5
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)
 	}
 }
 

+ 3 - 0
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)
 

+ 66 - 1
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)

+ 97 - 0
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{