Ver Fonte

feat(xray): update xray-core to v26.9.9 and follow the udpHop move

Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
Sanaei há 7 horas atrás
pai
commit
d0edbcec81

+ 2 - 2
.github/workflows/release.yml

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
           cd x-ui/bin
 
 
           # Download dependencies
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             fetch ${Xray_URL}Xray-linux-64.zip
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -287,7 +287,7 @@ jobs:
           cd x-ui\bin
           cd x-ui\bin
 
 
           # Download Xray for Windows
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 fi
 mkdir -p build/bin
 mkdir -p build/bin
 cd build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
 mv xray "xray-linux-${FNAME}"

+ 13 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -751,6 +751,18 @@ function hysteriaPinHex(pin: string): string {
   }
   }
 }
 }
 
 
+// Hysteria2 hop range advertised as `mport`. xray-core 26.9.9 moved hopping
+// from finalmask.quicParams.udpHop to a 'udphop' UDP mask; inbounds stored
+// before the upgrade still carry the old key.
+function udpHopPorts(stream: NonNullable<Inbound['streamSettings']>): string {
+  for (const mask of stream.finalmask?.udp ?? []) {
+    if (mask.type !== 'udphop') continue;
+    const ports = mask.settings?.remotePorts;
+    if (typeof ports === 'string' && ports.trim().length > 0) return ports.trim();
+  }
+  return stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
+}
+
 // Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
 // Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
 // The scheme is always hysteria2 — xray-core builds version 2 only, so the
 // The scheme is always hysteria2 — xray-core builds version 2 only, so the
 // settings schema pins it there and the subscription server emits the same
 // settings schema pins it there and the subscription server emits the same
@@ -817,7 +829,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     }
     }
   }
   }
 
 
-  const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
+  const hopPorts = udpHopPorts(stream);
   if (hopPorts.length > 0) {
   if (hopPorts.length > 0) {
     params.set('mport', hopPorts);
     params.set('mport', hopPorts);
   }
   }

+ 9 - 11
frontend/src/lib/xray/outbound-link-parser.ts

@@ -311,21 +311,19 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
   finalmask.udp = [...udp, { type: 'salamander', settings }];
   finalmask.udp = [...udp, { type: 'salamander', settings }];
 }
 }
 
 
-// Rebuild the UDP port-hopping range from the standard mport param, which the
-// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
-// via fm= wins; the client-side interval falls back to the panel's default.
+// Rebuild the UDP port-hopping range from the standard mport param. xray-core
+// 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
+// intervalremote mode is what the old key used to do; an fm= mask wins.
 function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
 function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
   const ports = firstParam(params, 'mport');
   const ports = firstParam(params, 'mport');
   if (!ports) return;
   if (!ports) return;
   const finalmask = ensureFinalMask(stream);
   const finalmask = ensureFinalMask(stream);
-  const quicParams = (
-    finalmask.quicParams && typeof finalmask.quicParams === 'object'
-      ? finalmask.quicParams
-      : (finalmask.quicParams = {})
-  ) as Raw;
-  const existingHop = quicParams.udpHop as Raw | undefined;
-  if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
-  quicParams.udpHop = { ports, interval: '5-10' };
+  const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
+  if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
+  finalmask.udp = [
+    ...udp,
+    { type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
+  ];
 }
 }
 
 
 const QUIC_PARAMS_NUMERIC_KEYS = [
 const QUIC_PARAMS_NUMERIC_KEYS = [

+ 6 - 3
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({
 });
 });
 export type TcpMask = z.infer<typeof TcpMaskSchema>;
 export type TcpMask = z.infer<typeof TcpMaskSchema>;
 
 
+// 'udphop' is client-only in xray-core (it refuses to wrap a server socket),
+// so it round-trips here but is deliberately absent from the mask dropdown.
 export const UdpMaskTypeSchema = z.enum([
 export const UdpMaskTypeSchema = z.enum([
   'salamander',
   'salamander',
   'mkcp-legacy',
   'mkcp-legacy',
@@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([
   'noise',
   'noise',
   'sudoku',
   'sudoku',
   'realm',
   'realm',
+  'udphop',
 ]);
 ]);
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 
 
@@ -43,9 +46,9 @@ export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
 export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
 export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
 export type BbrProfile = z.infer<typeof BbrProfileSchema>;
 export type BbrProfile = z.infer<typeof BbrProfileSchema>;
 
 
-// udpHop randomizes the QUIC port between a range every `interval` seconds
-// to dodge port-based blocking. Both fields are dash-range strings on the
-// wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows
+// udpHop declares the hop range advertised to clients as `mport`. xray-core
+// 26.9.9 moved actual hopping to the 'udphop' UDP mask and ignores this key,
+// which was always inert server-side. preprocess coerces legacy DB rows
 // where interval was stored as a number (UI bug — see B19 in commit history).
 // where interval was stored as a number (UI bug — see B19 in commit history).
 const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string());
 const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string());
 
 

+ 19 - 0
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -201,6 +201,25 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
 }
 }
 `;
 `;
 
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-hop byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "interval": "5-10",
+        "mode": "intervalremote",
+        "remoteIPs": [
+          "203.0.113.0/24",
+        ],
+        "remotePorts": "20000-50000",
+      },
+      "type": "udphop",
+    },
+  ],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
 exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
 {
 {
   "tcp": [],
   "tcp": [],

+ 13 - 0
frontend/src/test/golden/fixtures/finalmask/udp-hop.json

@@ -0,0 +1,13 @@
+{
+  "udp": [
+    {
+      "type": "udphop",
+      "settings": {
+        "mode": "intervalremote",
+        "interval": "5-10",
+        "remotePorts": "20000-50000",
+        "remoteIPs": ["203.0.113.0/24"]
+      }
+    }
+  ]
+}

+ 31 - 0
frontend/src/test/inbound-link.test.ts

@@ -233,6 +233,37 @@ describe('genHysteriaLink', () => {
     expect(link.endsWith('#hop-test')).toBe(true);
     expect(link.endsWith('#hop-test')).toBe(true);
   });
   });
 
 
+  it('emits mport from the udphop mask xray-core 26.9.9 moved hopping to', () => {
+    const [, raw] = fixtures[0];
+    const withHop = {
+      ...raw,
+      settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
+      streamSettings: {
+        ...(raw.streamSettings as Record<string, unknown>),
+        finalmask: {
+          udp: [
+            {
+              type: 'udphop',
+              settings: { mode: 'intervalremote', interval: '5-10', remotePorts: '30000-40000' },
+            },
+          ],
+        },
+      },
+    };
+    const typed = InboundSchema.parse(withHop);
+    const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
+
+    const link = genHysteriaLink({
+      inbound: typed,
+      address: 'example.test',
+      port: typed.port,
+      remark: 'hop-mask',
+      clientAuth: client.auth,
+    });
+
+    expect(link).toContain('mport=30000-40000');
+  });
+
   it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
   it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
     const [, raw] = fixtures[0];
     const [, raw] = fixtures[0];
     const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
     const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';

+ 23 - 13
frontend/src/test/outbound-link-parser.test.ts

@@ -443,7 +443,9 @@ describe('parseHysteria2Link', () => {
     expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
     expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
   });
   });
 
 
-  it('reconstructs udpHop from the standard mport param', () => {
+  // xray-core 26.9.9 ignores quicParams.udpHop; hopping is a 'udphop' UDP mask
+  // and its mode must be one the core's UDPHop.Build() accepts.
+  it('reconstructs a udphop mask from the standard mport param', () => {
     const out = parseHysteria2Link(
     const out = parseHysteria2Link(
       'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
       'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
     );
     );
@@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => {
       string,
       string,
       unknown
       unknown
     >;
     >;
-    const quic = finalmask.quicParams as Record<string, unknown>;
-    const udpHop = quic.udpHop as Record<string, unknown>;
-    expect(udpHop.ports).toBe('20000-50000');
-    expect(udpHop.interval).toBe('5-10');
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const hop = udp.find((mask) => mask.type === 'udphop');
+    expect(hop).toBeDefined();
+    const settings = hop!.settings as Record<string, unknown>;
+    expect(settings.remotePorts).toBe('20000-50000');
+    expect(settings.interval).toBe('5-10');
+    expect(settings.mode).toBe('intervalremote');
+    expect((finalmask.quicParams as Record<string, unknown> | undefined)?.udpHop).toBeUndefined();
   });
   });
 
 
-  it('lets an fm= udpHop win over mport', () => {
+  it('lets an fm= udphop mask win over mport', () => {
     const fm = encodeURIComponent(
     const fm = encodeURIComponent(
       JSON.stringify({
       JSON.stringify({
-        quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
+        udp: [
+          {
+            type: 'udphop',
+            settings: { mode: 'intervalremote', interval: '7-9', remotePorts: '30000-40000' },
+          },
+        ],
       }),
       }),
     );
     );
     const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
     const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
@@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => {
       string,
       string,
       unknown
       unknown
     >;
     >;
-    const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<
-      string,
-      unknown
-    >;
-    expect(udpHop.ports).toBe('30000-40000');
-    expect(udpHop.interval).toBe('7-9');
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(settings.remotePorts).toBe('30000-40000');
+    expect(settings.interval).toBe('7-9');
   });
   });
 
 
   it('round-trips the salamander packetSize (Gecko) under fm', () => {
   it('round-trips the salamander packetSize (Gecko) under fm', () => {

+ 1 - 1
go.mod

@@ -25,7 +25,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.74.0
 	github.com/valyala/fasthttp v1.74.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e
+	github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5
 	go.uber.org/atomic v1.11.0
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/net v0.58.0
 	golang.org/x/net v0.58.0

+ 2 - 2
go.sum

@@ -231,8 +231,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e h1:yQS0pPlOBi8y6bHNXbqx9AYito2pAXgItwKK7Waqaw8=
-github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e/go.mod h1:G9OqFEPNkwNoxywvEkdBncyfeytCzC31CBazgy3d9ic=
+github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI=
+github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 26 - 4
internal/sub/service.go

@@ -1368,18 +1368,39 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 }
 }
 
 
-// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
-// (finalmask.quicParams.udpHop.ports), or "" when port hopping is off. The
-// range is emitted as the v2rayN-compatible `mport` query param; the URL port
-// field stays numeric so .NET-Uri-based importers (v2rayN) can parse the link.
+// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range, or
+// "" when port hopping is off. The range is emitted as the v2rayN-compatible
+// `mport` query param; the URL port field stays numeric so .NET-Uri-based
+// importers (v2rayN) can parse the link.
 func hysteriaHopPorts(stream map[string]any) string {
 func hysteriaHopPorts(stream map[string]any) string {
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	finalmask, _ := stream["finalmask"].(map[string]any)
+	if ports := udpHopMaskPorts(finalmask); ports != "" {
+		return ports
+	}
 	quicParams, _ := finalmask["quicParams"].(map[string]any)
 	quicParams, _ := finalmask["quicParams"].(map[string]any)
 	udpHop, _ := quicParams["udpHop"].(map[string]any)
 	udpHop, _ := quicParams["udpHop"].(map[string]any)
 	ports, _ := udpHop["ports"].(string)
 	ports, _ := udpHop["ports"].(string)
 	return strings.TrimSpace(ports)
 	return strings.TrimSpace(ports)
 }
 }
 
 
+// udpHopMaskPorts reads remotePorts off the first "udphop" UDP mask. xray-core
+// 26.9.9 moved hopping here from finalmask.quicParams.udpHop, which it now ignores.
+func udpHopMaskPorts(finalmask map[string]any) string {
+	masks, _ := finalmask["udp"].([]any)
+	for _, rawMask := range masks {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType != "udphop" {
+			continue
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		ports, _ := settings["remotePorts"].(string)
+		if ports = strings.TrimSpace(ports); ports != "" {
+			return ports
+		}
+	}
+	return ""
+}
+
 // gecko packetSize bounds mirror xray-core's salamander buffer cap and the
 // gecko packetSize bounds mirror xray-core's salamander buffer cap and the
 // frontend editor, so both link generators emit identical URIs.
 // frontend editor, so both link generators emit identical URIs.
 const (
 const (
@@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{
 	"noise":         {},
 	"noise":         {},
 	"header-custom": {},
 	"header-custom": {},
 	"realm":         {},
 	"realm":         {},
+	"udphop":        {},
 }
 }
 
 
 var validFinalMaskTCPTypes = map[string]struct{}{
 var validFinalMaskTCPTypes = map[string]struct{}{

+ 33 - 0
internal/sub/service_test.go

@@ -1081,6 +1081,19 @@ func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) {
+	fm := map[string]any{
+		"udp": []any{udpHopMask("20000-50000")},
+	}
+	out, ok := marshalFinalMask(fm)
+	if !ok {
+		t.Fatal("expected ok=true for a udphop udp mask")
+	}
+	if !strings.Contains(out, "udphop") || !strings.Contains(out, "20000-50000") {
+		t.Fatalf("marshaled finalmask dropped the udphop mask: %s", out)
+	}
+}
+
 func TestHasFinalMaskContent(t *testing.T) {
 func TestHasFinalMaskContent(t *testing.T) {
 	if hasFinalMaskContent(nil) {
 	if hasFinalMaskContent(nil) {
 		t.Fatal("nil should not count as content")
 		t.Fatal("nil should not count as content")
@@ -1127,6 +1140,13 @@ func TestHysteriaPinHex(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func udpHopMask(ports string) map[string]any {
+	return map[string]any{
+		"type":     "udphop",
+		"settings": map[string]any{"mode": "intervalremote", "interval": "5-10", "remotePorts": ports},
+	}
+}
+
 func TestHysteriaHopPorts(t *testing.T) {
 func TestHysteriaHopPorts(t *testing.T) {
 	withHop := func(ports any) map[string]any {
 	withHop := func(ports any) map[string]any {
 		return map[string]any{
 		return map[string]any{
@@ -1137,6 +1157,11 @@ func TestHysteriaHopPorts(t *testing.T) {
 			},
 			},
 		}
 		}
 	}
 	}
+	withHopMask := func(ports string) map[string]any {
+		return map[string]any{
+			"finalmask": map[string]any{"udp": []any{udpHopMask(ports)}},
+		}
+	}
 
 
 	cases := []struct {
 	cases := []struct {
 		name   string
 		name   string
@@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) {
 		want   string
 		want   string
 	}{
 	}{
 		{"range", withHop("20000-50000"), "20000-50000"},
 		{"range", withHop("20000-50000"), "20000-50000"},
+		{"udphop mask", withHopMask("20000-50000"), "20000-50000"},
+		{"udphop mask wins over legacy key", map[string]any{
+			"finalmask": map[string]any{
+				"udp":        []any{udpHopMask("30000-40000")},
+				"quicParams": map[string]any{"udpHop": map[string]any{"ports": "20000-50000"}},
+			},
+		}, "30000-40000"},
+		{"udphop mask without remotePorts", withHopMask(""), ""},
 		{"trimmed", withHop("  443,20000-50000  "), "443,20000-50000"},
 		{"trimmed", withHop("  443,20000-50000  "), "443,20000-50000"},
 		{"empty string", withHop(""), ""},
 		{"empty string", withHop(""), ""},
 		{"non-string", withHop(float64(443)), ""},
 		{"non-string", withHop(float64(443)), ""},

+ 16 - 7
internal/util/link/outbound.go

@@ -760,21 +760,30 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) {
 }
 }
 
 
 // applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport
 // applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport
-// param, which the generator emits as finalmask.quicParams.udpHop.ports. A range
-// already supplied via fm= wins; the client-side interval falls back to the same
-// default the panel writes.
+// param. xray-core 26.9.9 replaced finalmask.quicParams.udpHop with a "udphop"
+// UDP mask, whose intervalremote mode is what the old key used to do; a mask
+// already supplied via fm= wins.
 func applyHysteria2Hop(stream map[string]any, p url.Values) {
 func applyHysteria2Hop(stream map[string]any, p url.Values) {
 	ports := firstParam(p, "mport")
 	ports := firstParam(p, "mport")
 	if ports == "" {
 	if ports == "" {
 		return
 		return
 	}
 	}
-	quicParams := ensureChildMap(ensureChildMap(stream, "finalmask"), "quicParams")
-	if udpHop, ok := quicParams["udpHop"].(map[string]any); ok {
-		if existing, _ := udpHop["ports"].(string); existing != "" {
+	finalmask := ensureChildMap(stream, "finalmask")
+	masks, _ := finalmask["udp"].([]any)
+	for _, rawMask := range masks {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType == "udphop" {
 			return
 			return
 		}
 		}
 	}
 	}
-	quicParams["udpHop"] = map[string]any{"ports": ports, "interval": "5-10"}
+	finalmask["udp"] = append(masks, map[string]any{
+		"type": "udphop",
+		"settings": map[string]any{
+			"mode":        "intervalremote",
+			"interval":    "5-10",
+			"remotePorts": ports,
+		},
+	})
 }
 }
 
 
 func ensureChildMap(parent map[string]any, key string) map[string]any {
 func ensureChildMap(parent map[string]any, key string) map[string]any {

+ 48 - 7
internal/util/link/outbound_test.go

@@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any {
 	return udp
 	return udp
 }
 }
 
 
+func hopMask(t *testing.T, res *ParseResult) (map[string]any, bool) {
+	t.Helper()
+	for _, rawMask := range finalmaskUDP(t, res) {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType == "udphop" {
+			settings, _ := mask["settings"].(map[string]any)
+			return settings, true
+		}
+	}
+	return nil, false
+}
+
 func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
 func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
 	t.Helper()
 	t.Helper()
-	stream, _ := res.Outbound["streamSettings"].(map[string]any)
-	finalmask, _ := stream["finalmask"].(map[string]any)
-	quicParams, _ := finalmask["quicParams"].(map[string]any)
-	udpHop, ok := quicParams["udpHop"].(map[string]any)
+	settings, ok := hopMask(t, res)
 	if !ok {
 	if !ok {
 		return "", false
 		return "", false
 	}
 	}
-	ports, _ := udpHop["ports"].(string)
+	ports, _ := settings["remotePorts"].(string)
 	return ports, true
 	return ports, true
 }
 }
 
 
@@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) {
 		{"standard mport", "mport=20000-50000", "20000-50000", true},
 		{"standard mport", "mport=20000-50000", "20000-50000", true},
 		{"no mport", "sni=ex.com", "", false},
 		{"no mport", "sni=ex.com", "", false},
 		{
 		{
-			name:      "fm udpHop wins over mport",
-			query:     "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
+			name:      "fm udphop mask wins over mport",
+			query:     "mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalremote","interval":"7-9","remotePorts":"30000-40000"}}]}`),
 			wantPorts: "30000-40000",
 			wantPorts: "30000-40000",
 			wantHop:   true,
 			wantHop:   true,
 		},
 		},
+		{
+			name:      "legacy fm quicParams.udpHop no longer suppresses mport",
+			query:     "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
+			wantPorts: "1-2",
+			wantHop:   true,
+		},
 	}
 	}
 	for _, c := range cases {
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 		t.Run(c.name, func(t *testing.T) {
@@ -281,6 +296,32 @@ func TestParseHysteria2_Mport(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// xray-core 26.9.9 rejects a udphop mask whose mode is empty or unknown, so
+// the mport importer must emit a mode the core's UDPHop.Build() accepts.
+func TestParseHysteria2_MportEmitsCoreAcceptedMask(t *testing.T) {
+	res, err := ParseLink("hysteria2://[email protected]:443?security=tls&mport=20000-50000#node")
+	if err != nil {
+		t.Fatalf("parse hysteria2: %v", err)
+	}
+	settings, ok := hopMask(t, res)
+	if !ok {
+		t.Fatalf("no udphop mask (stream: %v)", res.Outbound["streamSettings"])
+	}
+	if got, _ := settings["mode"].(string); got != "intervalremote" {
+		t.Errorf("mode = %q, want %q", got, "intervalremote")
+	}
+	if got, _ := settings["interval"].(string); got != "5-10" {
+		t.Errorf("interval = %q, want %q", got, "5-10")
+	}
+	stream, _ := res.Outbound["streamSettings"].(map[string]any)
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if quicParams, ok := finalmask["quicParams"].(map[string]any); ok {
+		if _, dead := quicParams["udpHop"]; dead {
+			t.Error("importer still writes the quicParams.udpHop key the core ignores")
+		}
+	}
+}
+
 func TestParseShadowsocks(t *testing.T) {
 func TestParseShadowsocks(t *testing.T) {
 	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
 	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
 	legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:8388"))
 	legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:8388"))