diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dde15ed28..2e44a8db3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,7 +124,7 @@ jobs: cd x-ui/bin # 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 fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -287,7 +287,7 @@ jobs: cd x-ui\bin # 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" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index 1d9b1861f..6c7d1795d 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p 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" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 230c611cd..555d4846e 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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): 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://@:?#. // 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 @@ -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) { params.set('mport', hopPorts); } diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index c171a95fe..1f539591c 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/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 }]; } -// 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 { const ports = firstParam(params, 'mport'); if (!ports) return; 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 = [ diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 1bf1168a6..874e9be3d 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({ }); export type TcpMask = z.infer; +// '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([ 'salamander', 'mkcp-legacy', @@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([ 'noise', 'sudoku', 'realm', + 'udphop', ]); export type UdpMaskType = z.infer; @@ -43,9 +46,9 @@ export type QuicCongestion = z.infer; export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']); export type BbrProfile = z.infer; -// 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). const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string()); diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index a984b2c08..2bddca3e3 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/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`] = ` { "tcp": [], diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-hop.json b/frontend/src/test/golden/fixtures/finalmask/udp-hop.json new file mode 100644 index 000000000..c27f28eb1 --- /dev/null +++ b/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"] + } + } + ] +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index b39728a7e..ca425215b 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -233,6 +233,37 @@ describe('genHysteriaLink', () => { 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), version: 2 }, + streamSettings: { + ...(raw.streamSettings as Record), + 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)', () => { const [, raw] = fixtures[0]; const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index e0e7189e1..9991a3294 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -443,7 +443,9 @@ describe('parseHysteria2Link', () => { expect((udp[0].settings as Record).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( 'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport', ); @@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => { string, unknown >; - const quic = finalmask.quicParams as Record; - const udpHop = quic.udpHop as Record; - expect(udpHop.ports).toBe('20000-50000'); - expect(udpHop.interval).toBe('5-10'); + const udp = finalmask.udp as Array>; + const hop = udp.find((mask) => mask.type === 'udphop'); + expect(hop).toBeDefined(); + const settings = hop!.settings as Record; + expect(settings.remotePorts).toBe('20000-50000'); + expect(settings.interval).toBe('5-10'); + expect(settings.mode).toBe('intervalremote'); + expect((finalmask.quicParams as Record | undefined)?.udpHop).toBeUndefined(); }); - it('lets an fm= udpHop win over mport', () => { + it('lets an fm= udphop mask win over mport', () => { const fm = encodeURIComponent( 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`; @@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => { string, unknown >; - const udpHop = (finalmask.quicParams as Record).udpHop as Record< - string, - unknown - >; - expect(udpHop.ports).toBe('30000-40000'); - expect(udpHop.interval).toBe('7-9'); + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(1); + const settings = udp[0].settings as Record; + expect(settings.remotePorts).toBe('30000-40000'); + expect(settings.interval).toBe('7-9'); }); it('round-trips the salamander packetSize (Gecko) under fm', () => { diff --git a/go.mod b/go.mod index 19532e64e..841653d64 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.74.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 golang.org/x/crypto v0.56.0 golang.org/x/net v0.58.0 diff --git a/go.sum b/go.sum index e49359816..2375aeb0e 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/internal/sub/service.go b/internal/sub/service.go index 9a6b698d4..b6d423fd9 100644 --- a/internal/sub/service.go +++ b/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")) } -// 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 { finalmask, _ := stream["finalmask"].(map[string]any) + if ports := udpHopMaskPorts(finalmask); ports != "" { + return ports + } quicParams, _ := finalmask["quicParams"].(map[string]any) udpHop, _ := quicParams["udpHop"].(map[string]any) ports, _ := udpHop["ports"].(string) 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 // frontend editor, so both link generators emit identical URIs. const ( @@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{ "noise": {}, "header-custom": {}, "realm": {}, + "udphop": {}, } var validFinalMaskTCPTypes = map[string]struct{}{ diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 5904f1898..a69c6eec4 100644 --- a/internal/sub/service_test.go +++ b/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) { if hasFinalMaskContent(nil) { 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) { withHop := func(ports any) 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 { name string @@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) { want string }{ {"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"}, {"empty string", withHop(""), ""}, {"non-string", withHop(float64(443)), ""}, diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 25dde4e20..f6206646f 100644 --- a/internal/util/link/outbound.go +++ b/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 -// 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) { ports := firstParam(p, "mport") if ports == "" { 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 } } - 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 { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index eabfc6b2e..acbd0b095 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any { 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) { 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 { return "", false } - ports, _ := udpHop["ports"].(string) + ports, _ := settings["remotePorts"].(string) return ports, true } @@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) { {"standard mport", "mport=20000-50000", "20000-50000", true}, {"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", 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 { 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://auth@1.2.3.4: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) { modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass")) legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))