From ec0a57fc4f9d06fe330382691e4321cc267f87a8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 11:20:34 +0200 Subject: [PATCH] fix(finalmask): hop client UDP through the udphop mask xray-core 26.9.9 dropped finalmask.quicParams.udpHop for the client-only "udphop" UDP mask {mode, interval, remoteIPs, remotePorts}. The panel kept writing the old key, so hysteria/xhttp outbounds and JSON-subscription clients silently stopped hopping ports. An inbound keeps quicParams.udpHop: it is panel metadata advertised as the link's mport, and a udphop mask on a listener fails with "udphop: client only". The conversion (maskcompat.UpgradeLegacyUDPHop, intervalRemote mode) runs only where a client config is produced: - UDPHopClientMaskFix seeder over hosts, sub-JSON finalmask, cached outbound-subscription outbounds and template outbounds (never inbounds); - GetXrayConfig heals template outbounds at build time; - the JSON subscription upgrades each generated client stream; - the Go and frontend link importers upgrade a legacy fm= param. The finalmask editor gains a side prop: the client side lifts the dead key on mount and offers a udphop mask editor (also for xhttp's HTTP/3 dial); the inbound side keeps the UDP Hop switch, labelled as advertised. --- docs/content/docs/en/config/transports.mdx | 13 +- docs/content/docs/fa/config/transports.mdx | 13 +- docs/content/docs/ru/config/transports.mdx | 13 +- docs/content/docs/zh/config/transports.mdx | 9 +- .../lib/xray/forms/fields/FinalMaskField.tsx | 3 + .../xray/forms/transport/FinalMaskForm.tsx | 150 +++++++++++++++--- frontend/src/lib/xray/outbound-link-parser.ts | 7 +- frontend/src/lib/xray/udphop-mask.ts | 29 ++++ .../pages/inbounds/form/InboundFormModal.tsx | 1 + frontend/src/test/inbound-form-modal.test.tsx | 50 ++++++ .../src/test/outbound-form-modal.test.tsx | 64 ++++++++ .../src/test/outbound-link-parser.test.ts | 29 +++- internal/database/db.go | 84 ++++++++-- .../udphop_client_mask_migration_test.go | 117 ++++++++++++++ internal/sub/json_service.go | 4 + internal/sub/masque_test.go | 33 ++++ internal/util/link/outbound.go | 1 + internal/util/link/outbound_test.go | 23 ++- internal/util/maskcompat/udphop.go | 54 +++++++ internal/util/maskcompat/udphop_test.go | 106 +++++++++++++ .../service/inbound_finalmask_xdns_test.go | 35 ++++ internal/web/service/xray.go | 10 +- 22 files changed, 797 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/xray/udphop-mask.ts create mode 100644 internal/database/udphop_client_mask_migration_test.go create mode 100644 internal/util/maskcompat/udphop.go create mode 100644 internal/util/maskcompat/udphop_test.go diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 6033aa7fa..dbf45a460 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -147,11 +147,16 @@ TLS. Masks are configured per direction: stream as Minecraft protocol traffic; requires a password, with optional hostname and player usernames). - **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header - obfuscation.) + `noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`mkcp-legacy` + reproduces the old mKCP header obfuscation; `udphop` rotates the remote port — + and optionally the remote IP — on an interval or per connection to dodge port + blocking.) - **QUIC params** — congestion control (`reno`, `bbr`, `brutal`, `force-brutal`), - Brutal up/down rates, `udpHop` (rotate the QUIC port across a range to dodge - port blocking), and receive-window tuning. + Brutal up/down rates, and receive-window tuning. On an inbound, **UDP Hop** sets + the port range advertised to clients (the share link's `mport` and the JSON + subscription's `udphop` mask); the server still listens on its own port. Since + Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts + an older client `quicParams.udpHop` to it. FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray builds exposed. diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index f87f9eda8..0e02eb1b8 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -148,11 +148,16 @@ TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن اختیاری‌اند). - **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`، - `noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را - بازتولید می‌کند.) + `noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy` + همان مبهم‌سازی قدیمی هدر mKCP را بازتولید می‌کند؛ `udphop` پورت مقصد — و در صورت + نیاز IP مقصد — را در فواصل زمانی یا به‌ازای هر اتصال عوض می‌کند تا مسدودسازی پورت + دور زده شود.) - **پارامترهای QUIC** — کنترل ازدحام (`reno`، `bbr`، `brutal`، `force-brutal`)، - نرخ‌های آپلود/دانلود Brutal، `udpHop` (چرخاندن پورت QUIC در یک بازه برای دور زدن - مسدودسازی پورت)، و تنظیم پنجره دریافت. + نرخ‌های آپلود/دانلود Brutal، و تنظیم پنجره دریافت. در اینباند، **UDP Hop** بازه‌ی + پورتی را تعیین می‌کند که به کلاینت‌ها اعلام می‌شود (`mport` در لینک اشتراک و ماسک + `udphop` در اشتراک JSON)؛ خود سرور همچنان روی پورت خودش گوش می‌دهد. از Xray + 26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض می‌کند، برای همین پنل `quicParams.udpHop` + قدیمی کلاینت را به آن تبدیل می‌کند. ‏FinalMask جایگزین مبهم‌سازی `header`/`seed` به‌ازای هر انتقال می‌شود که بیلدهای قدیمی‌تر Xray نمایش می‌دادند. diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index 72771a9f1..c7b5ad51f 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -150,11 +150,16 @@ XDRIVE передаёт поток файлами в папке, доступн под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков опциональны). - **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию - заголовка mKCP.) + `noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy` + воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт — + и при желании удалённый IP — по интервалу или для каждого соединения, чтобы + обходить блокировку портов.) - **Параметры QUIC** — управление перегрузкой (`reno`, `bbr`, `brutal`, - `force-brutal`), скорости отдачи/приёма Brutal, `udpHop` (ротация QUIC-порта в - пределах диапазона для обхода блокировки портов) и настройка окна приёма. + `force-brutal`), скорости отдачи/приёма Brutal и настройка окна приёма. На + инбаунде **UDP Hop** задаёт диапазон портов, который объявляется клиентам + (`mport` в ссылке и маска `udphop` в JSON-подписке); сам сервер слушает свой + порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`, + поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё. FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта, которую предоставляли старые сборки Xray. diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index 3326d712e..3e04286ec 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -138,9 +138,14 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 - **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成 Minecraft 协议;密码必填,主机名和玩家用户名可选)。 - **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、 - `noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。) + `noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版 + mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避 + 端口封锁。) - **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行 - 速率、`udpHop`(在一个范围内轮换 QUIC 端口以规避端口封锁)以及接收窗口调优。 + 速率以及接收窗口调优。在入站上,**UDP Hop** 设置向客户端公布的端口范围(分享链接的 + `mport` 和 JSON 订阅中的 `udphop` 掩码);服务器本身仍只监听自己的端口。自 Xray + 26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的 + `quicParams.udpHop` 转换为该掩码。 FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。 diff --git a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx index 0ca265f6f..10a83d690 100644 --- a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx +++ b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx @@ -10,6 +10,7 @@ interface FinalMaskFieldProps { network: string; protocol: string; showAll?: boolean; + side?: 'server' | 'client'; } const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] }; @@ -20,6 +21,7 @@ export default function FinalMaskField({ network, protocol, showAll, + side, }: FinalMaskFieldProps) { const [form] = Form.useForm(); const [initial] = useState(() => value ?? EMPTY); @@ -55,6 +57,7 @@ export default function FinalMaskField({ protocol={protocol} form={form} showAll={showAll} + side={side} /> ); diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index c9a5137da..7bff0ba7a 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -18,6 +18,7 @@ import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { activateOnKey } from '@/utils/a11y'; import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives'; +import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask'; import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask'; const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ @@ -34,6 +35,9 @@ export interface FinalMaskFormProps { // network/protocol. Used by the global sub-JSON finalmask editor where // the masks apply to every stream rather than one specific transport. showAll?: boolean; + // udphop runs only on the dialing side (the core refuses it on a listener), so a server + // keeps the advertised hop range in quicParams.udpHop and a client gets the mask. + side?: 'server' | 'client'; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp', 'xdrive']; @@ -194,6 +198,8 @@ function defaultUdpMaskSettings(type: string): Record { return { client: [], server: [] }; case 'noise': return { reset: 0, noise: [] }; + case 'udphop': + return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' }; default: return {}; } @@ -242,6 +248,7 @@ export default function FinalMaskForm({ protocol, form, showAll = false, + side = 'client', }: FinalMaskFormProps) { const base = asPath(name); @@ -273,6 +280,13 @@ export default function FinalMaskForm({ const { next, changed } = upgradeLegacyXdnsMasks(udp); if (changed) form.setFieldValue([...base, 'udp'], next); } + if (side === 'client') { + const finalmask = form.getFieldValue(base) as Record | undefined; + if (finalmask && typeof finalmask === 'object') { + const { next, changed } = upgradeLegacyUdpHop(finalmask); + if (changed) form.setFieldValue(base, next); + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -282,7 +296,13 @@ export default function FinalMaskForm({ // wrap anything even though the leftover network value may be 'tcp'. const isWireguard = protocol === 'wireguard'; const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network)); - const showUdp = showAll || isHysteria || isWireguard || network === 'kcp'; + // xhttp's HTTP/3 dial runs the UDP masks too, which is where a client's udphop lives. + const showUdp = + showAll || + isHysteria || + isWireguard || + network === 'kcp' || + (side === 'client' && network === 'xhttp'); const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -299,6 +319,7 @@ export default function FinalMaskForm({ isHysteria={isHysteria} isWireguard={isWireguard} network={network} + allowUdpHop={side === 'client'} /> )} {showQuic && ( @@ -311,7 +332,13 @@ export default function FinalMaskForm({ }} /> - {hasQuicParams && } + {hasQuicParams && ( + + )} )} @@ -820,12 +847,14 @@ function UdpMasksList({ isHysteria, isWireguard, network, + allowUdpHop, }: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string; + allowUdpHop: boolean; }) { const { t } = useTranslation(); return ( @@ -854,6 +883,7 @@ function UdpMasksList({ isHysteria={isHysteria} isWireguard={isWireguard} network={network} + allowUdpHop={allowUdpHop} onRemove={() => remove(field.name)} /> ))} @@ -871,6 +901,7 @@ function UdpMaskItem({ isHysteria, isWireguard, network, + allowUdpHop, onRemove, }: { fieldName: number; @@ -880,6 +911,7 @@ function UdpMaskItem({ isHysteria: boolean; isWireguard: boolean; network: string; + allowUdpHop: boolean; onRemove: () => void; }) { const absolutePath = [...listPath, fieldName]; @@ -893,8 +925,9 @@ function UdpMaskItem({ } }; + const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : []; const options = isHysteria - ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] + ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption] : [ // Salamander is the mask xray-core's own wireguard finalmask example // uses; it stays hysteria-only elsewhere to keep legacy parity. @@ -905,6 +938,7 @@ function UdpMaskItem({ { value: 'realm', label: 'Realm' }, { value: 'header-custom', label: 'Header Custom' }, { value: 'noise', label: 'Noise' }, + ...udpHopOption, ]; return ( @@ -967,6 +1001,9 @@ function UdpMaskItem({ if (type === 'xdns') { return ; } + if (type === 'udphop') { + return ; + } if (type === 'xicmp') { return ( <> @@ -1366,6 +1403,66 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { ); } +const UDP_HOP_MODES = [ + { value: 'intervalRemote', label: 'Rotate server port (interval)' }, + { value: 'perConnRemote', label: 'Random server port per connection' }, + { value: 'intervalLocal', label: 'Rotate local port (interval)' }, +]; + +// xray-core lowercases each comma-separated mode, so a lowercase one (from an mport +// import) still maps onto its option here. +function udpHopModesFromWire(value: unknown): string[] { + if (typeof value !== 'string') return []; + return value + .split(',') + .map((mode) => mode.trim()) + .filter(Boolean) + .map( + (mode) => + UDP_HOP_MODES.find((m) => m.value.toLowerCase() === mode.toLowerCase())?.value ?? mode, + ); +} + +function validateUdpHopInterval(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + const bounds = String(value) + .split('-') + .map((v) => Number(v.trim())); + const valid = bounds.length <= 2 && bounds.every((v) => Number.isInteger(v) && v >= 5); + return valid ? Promise.resolve() : Promise.reject(new Error('seconds ≥ 5, e.g. 30 or 5-10')); +} + +// Client-only hopping (xray-core 26.9.9); every key needs a registered field because the +// finalmask watch drops keys without one. +function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) { + return ( + <> + ({ value: udpHopModesFromWire(value) })} + normalize={(value: string[]) => value.join(',')} + rules={[{ required: true, message: 'pick at least one mode' }]} + > + + + + + + + - - - + + { + form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined); + }} + /> + {hasUdpHop && ( + <> + + + + + + + + )} )} diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index b2e085a11..31c571668 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -1,5 +1,6 @@ import { Base64 } from '@/utils'; +import { upgradeLegacyUdpHop } from './udphop-mask'; import { upgradeLegacyXdnsMasks } from './xdns-mask'; // Focused share-link parser for the OutboundFormModal's link-import @@ -272,8 +273,8 @@ const kcpHeaderTypeToMask: Record = { }; // The inbound link emits the entire finalmask object as a JSON-encoded -// `fm` query param. Decode and attach to streamSettings so udpHop / -// quicParams / tcp+udp masks round-trip on outbound import. +// `fm` query param. Decode and attach to streamSettings so quicParams and the +// tcp+udp masks round-trip on outbound import; a legacy udpHop becomes the mask. function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { const fm = params.get('fm'); if (fm) { @@ -282,7 +283,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { if (parsed && typeof parsed === 'object') { sanitizeFinalMaskQuicParams(parsed); if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next; - stream.finalmask = parsed; + stream.finalmask = upgradeLegacyUdpHop(parsed).next; } } catch { // malformed fm — leave streamSettings.finalmask absent diff --git a/frontend/src/lib/xray/udphop-mask.ts b/frontend/src/lib/xray/udphop-mask.ts new file mode 100644 index 000000000..fea44f8d2 --- /dev/null +++ b/frontend/src/lib/xray/udphop-mask.ts @@ -0,0 +1,29 @@ +type Raw = Record; + +function isEmptyHopValue(value: unknown): boolean { + return value == null || value === 0 || (typeof value === 'string' && value.trim() === ''); +} + +// xray-core 26.9.9 hops a client only through the "udphop" UDP mask. Mirrors +// internal/util/maskcompat: quicParams.udpHop becomes an intervalRemote mask. +export function upgradeLegacyUdpHop(finalmask: Raw): { next: Raw; changed: boolean } { + const quicParams = finalmask.quicParams; + if (!quicParams || typeof quicParams !== 'object' || !('udpHop' in quicParams)) { + return { next: finalmask, changed: false }; + } + const { udpHop, ...restQuic } = quicParams as Raw; + const next: Raw = { ...finalmask }; + if (Object.keys(restQuic).length > 0) next.quicParams = restQuic; + else delete next.quicParams; + + const hop = (udpHop && typeof udpHop === 'object' ? udpHop : {}) as Raw; + const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : []; + const hasMask = udp.some((m) => String((m as Raw | null)?.type ?? '').toLowerCase() === 'udphop'); + if (!isEmptyHopValue(hop.ports) && !hasMask) { + const settings: Raw = { mode: 'intervalRemote', remotePorts: hop.ports }; + if (!isEmptyHopValue(hop.interval)) settings.interval = hop.interval; + udp.push({ type: 'udphop', settings }); + next.udp = udp; + } + return { next, changed: true }; +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 4563d97d2..8b7bd5dd7 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -998,6 +998,7 @@ export default function InboundFormModal({ onChange={field.onChange} network={network} protocol={protocol} + side="server" /> )} /> diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 0b5f881c2..8aa5bb547 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -417,6 +417,56 @@ describe('InboundFormModal', () => { }); }); + // xray-core refuses a udphop mask on a listener ("client only"), so an inbound keeps its + // advertised hop range in quicParams.udpHop: the client-side lift must not run here. + it('keeps an inbound hop range in quicParams.udpHop on save', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + const hysteria = new DBInbound({ + id: 43, + port: 443, + listen: '', + protocol: 'hysteria', + remark: 'hy', + enable: true, + settings: { version: 2, clients: [] }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60 }, + tlsSettings: { + serverName: 'hy.example.com', + alpn: ['h3'], + certificates: [ + { certificateFile: '/etc/ssl/certs/hy.crt', keyFile: '/etc/ssl/private/hy.key' }, + ], + }, + finalmask: { quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }, + }, + sniffing: { enabled: false }, + nodeId: null, + shareAddrStrategy: 'listen', + shareAddr: '', + }); + renderCloneLikeEdit(hysteria); + // Form watches flush in a macrotask; save only once the hop fields have emitted. + await screen.findByDisplayValue('20000-30000'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + fireEvent.click(primaryButton()); + + const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/43'; + await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true)); + const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string }; + const finalmask = JSON.parse(payload.streamSettings).finalmask as Record; + expect(finalmask.quicParams).toMatchObject({ + udpHop: { ports: '20000-30000', interval: '5-10' }, + }); + expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop'); + }); + // Clients and enable change through their own endpoints; the server keeps the // stored ones, so the edit form must neither send nor validate its stale copy. it('edit save neither sends nor validates the clients it loaded', async () => { diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 60ce26e5d..489cf2b2a 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -452,3 +452,67 @@ describe('OutboundFormModal MASQUE', () => { expect(payload.streamSettings.masqueSettings.pass).toBeUndefined(); }); }); + +describe('OutboundFormModal UDP hop', () => { + // xray-core 26.9.9 ignores quicParams.udpHop; a client hops only through the udphop + // mask, so the editor must lift the dead key and keep every mask field on save. + it('saves a legacy quicParams.udpHop hysteria outbound as a udphop mask', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'hysteria', + tag: 'hy-out', + settings: { address: 'hy.example.com', port: 443, version: 2 }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: 'a', udpIdleTimeout: 60 }, + tlsSettings: { serverName: 'hy.example.com', alpn: ['h3'] }, + finalmask: { + udp: [{ type: 'salamander', settings: { password: 'p' } }], + quicParams: { congestion: 'bbr', udpHop: { ports: '20000-30000', interval: '5-10' } }, + }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + streamSettings: { finalmask: { udp: unknown[]; quicParams?: Record } }; + }; + expect(payload.streamSettings.finalmask.udp).toEqual([ + { type: 'salamander', settings: { password: 'p' } }, + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ]); + expect(payload.streamSettings.finalmask.quicParams?.udpHop).toBeUndefined(); + expect(payload.streamSettings.finalmask.quicParams?.congestion).toBe('bbr'); + }); +}); diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 5de0b78d1..6b14302c9 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -488,6 +488,25 @@ describe('parseShadowsocksLink', () => { }); describe('parseHysteria2Link', () => { + // A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop in fm=, + // which the core ignores; the import must hand it the udphop mask instead. + it('upgrades a legacy fm= quicParams.udpHop to the udphop mask', () => { + const fm = encodeURIComponent( + JSON.stringify({ quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }), + ); + const out = parseHysteria2Link( + `hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=${fm}#hy`, + ); + expect((out!.streamSettings as Record).finalmask).toEqual({ + udp: [ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ], + }); + }); + it('parses a hysteria2:// link with sni', () => { const link = 'hysteria2://auth-secret@srv.example:443?sni=example.com#imported-hy2'; const out = parseHysteria2Link(link); @@ -822,8 +841,14 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { const quicParams = finalmask.quicParams as Record; expect(quicParams.congestion).toBe('bbr'); expect(quicParams.maxIdleTimeout).toBe(30); - expect((quicParams.udpHop as Record).interval).toBe('5-10'); - expect((quicParams.udpHop as Record).ports).toBe('20000-50000'); + // xray-core 26.9.9 ignores quicParams.udpHop; the import moves it to the udphop mask. + expect(quicParams.udpHop).toBeUndefined(); + expect(finalmask.udp).toEqual([ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-50000', interval: '5-10' }, + }, + ]); }); it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => { diff --git a/internal/database/db.go b/internal/database/db.go index 25bfa95a7..4e555ce35 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1282,7 +1282,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1429,6 +1429,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "UDPHopClientMaskFix") { + if err := migrateUDPHopClientMasks(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "NodeInboundsAdopted") { if err := seedNodeInboundsAdopted(); err != nil { return err @@ -1937,7 +1943,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, inbound := range inbounds { - if updated, changed := upgradeLegacyXdnsJSON(inbound.StreamSettings, streamFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(inbound.StreamSettings, streamFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id). Update("stream_settings", updated).Error; err != nil { return err @@ -1949,7 +1955,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, host := range hosts { - if updated, changed := upgradeLegacyXdnsJSON(host.FinalMask, wholeFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). Update("final_mask", updated).Error; err != nil { return err @@ -1961,7 +1967,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, sub := range subs { - if updated, changed := upgradeLegacyXdnsJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false); changed { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). Update("last_fetched_outbounds", updated).Error; err != nil { return err @@ -1984,7 +1990,7 @@ func migrateXdnsFinalmaskShape() error { if err != nil { return err } - if updated, changed := upgradeLegacyXdnsJSON(setting.Value, stored.locate, stored.indent); changed { + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). Update("value", updated).Error; err != nil { return err @@ -1995,9 +2001,64 @@ func migrateXdnsFinalmaskShape() error { }) } -// upgradeLegacyXdnsJSON rewrites the finalmasks locate finds in one stored JSON document, -// leaving the document byte-for-byte alone when nothing in it is legacy. -func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (string, bool) { +// migrateUDPHopClientMasks moves the quicParams.udpHop xray-core 26.9.9 ignores into a +// udphop mask in every client-side finalmask; inbounds keep it, udphop refuses to run there. +func migrateUDPHopClientMasks() error { + return db.Transaction(func(tx *gorm.DB) error { + var hosts []model.Host + if err := tx.Select("id", "final_mask").Find(&hosts).Error; err != nil { + return err + } + for _, host := range hosts { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). + Update("final_mask", updated).Error; err != nil { + return err + } + } + } + var subs []model.OutboundSubscription + if err := tx.Select("id", "last_fetched_outbounds").Find(&subs).Error; err != nil { + return err + } + for _, sub := range subs { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). + Update("last_fetched_outbounds", updated).Error; err != nil { + return err + } + } + } + for _, stored := range []struct { + key string + locate func(any) []any + indent bool + }{ + {"xrayTemplateConfig", templateOutboundFinalmasks, true}, + {"subJsonFinalMask", wholeFinalmask, false}, + } { + var setting model.Setting + err := tx.Where("key = ?", stored.key).First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + return err + } + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). + Update("value", updated).Error; err != nil { + return err + } + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "UDPHopClientMaskFix"}).Error + }) +} + +// upgradeFinalmasksJSON runs upgrade on the finalmasks locate finds in one stored JSON +// document, leaving the document byte-for-byte alone when nothing in it changed. +func upgradeFinalmasksJSON(raw string, locate func(any) []any, indent bool, upgrade func(any) bool) (string, bool) { if strings.TrimSpace(raw) == "" { return raw, false } @@ -2007,7 +2068,7 @@ func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (str } changed := false for _, mask := range locate(doc) { - if maskcompat.UpgradeLegacyXdns(mask) { + if upgrade(mask) { changed = true } } @@ -2044,6 +2105,11 @@ func outboundListFinalmasks(doc any) []any { return finalmasks } +func templateOutboundFinalmasks(doc any) []any { + cfg, _ := doc.(map[string]any) + return outboundListFinalmasks(cfg["outbounds"]) +} + func templateFinalmasks(doc any) []any { cfg, _ := doc.(map[string]any) return append(outboundListFinalmasks(cfg["inbounds"]), outboundListFinalmasks(cfg["outbounds"])...) diff --git a/internal/database/udphop_client_mask_migration_test.go b/internal/database/udphop_client_mask_migration_test.go new file mode 100644 index 000000000..2e38026ce --- /dev/null +++ b/internal/database/udphop_client_mask_migration_test.go @@ -0,0 +1,117 @@ +package database + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const legacyHopFinalmask = `{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}` + +const upgradedHopFinalmask = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + +func assertHopUpgraded(t *testing.T, where string, finalmask any) { + t.Helper() + got, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("%s: marshal finalmask: %v", where, err) + } + if string(got) != upgradedHopFinalmask { + t.Fatalf("%s: finalmask\n got: %s\nwant: %s", where, got, upgradedHopFinalmask) + } +} + +// xray-core 26.9.9 ignores quicParams.udpHop, so every client-side finalmask the panel +// stored before then hops nowhere; inbounds keep it, since udphop refuses to run there. +func TestUDPHopSeederMovesClientHopsToTheMask(t *testing.T) { + initMigrateDB(t) + inboundStream := `{"network":"hysteria","security":"tls","finalmask":` + legacyHopFinalmask + `}` + ib := seedInboundWithStream(t, "hy-in", 8443, inboundStream) + host := &model.Host{InboundId: ib.Id, Remark: "h", Address: "cdn.example.com", Port: 8443, FinalMask: legacyHopFinalmask} + if err := GetDB().Create(host).Error; err != nil { + t.Fatalf("create host: %v", err) + } + seedTemplate(t, `{"inbounds":[{"tag":"tpl-in","streamSettings":{"finalmask":`+legacyHopFinalmask+`}}], + "outbounds":[{"protocol":"hysteria","tag":"hy-out","settings":{},"streamSettings":{"network":"hysteria","finalmask":`+legacyHopFinalmask+`}}]}`) + if err := GetDB().Create(&model.Setting{Key: "subJsonFinalMask", Value: legacyHopFinalmask}).Error; err != nil { + t.Fatalf("seed subJsonFinalMask: %v", err) + } + sub := &model.OutboundSubscription{ + Remark: "donor", Url: "https://donor.example.com/sub", + LastFetchedOutbounds: `[{"protocol":"hysteria","tag":"sub-1","settings":{},"streamSettings":{"network":"hysteria","finalmask":` + legacyHopFinalmask + `}}]`, + } + if err := GetDB().Create(sub).Error; err != nil { + t.Fatalf("create outbound subscription: %v", err) + } + if err := GetDB().Where("seeder_name = ?", "UDPHopClientMaskFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + + if err := runSeeders(false); err != nil { + t.Fatalf("runSeeders: %v", err) + } + + var storedHost model.Host + if err := GetDB().First(&storedHost, host.Id).Error; err != nil { + t.Fatalf("reload host: %v", err) + } + var hostMask any + _ = json.Unmarshal([]byte(storedHost.FinalMask), &hostMask) + assertHopUpgraded(t, "host finalMask", hostMask) + + var template struct { + Inbounds []struct { + StreamSettings struct { + Finalmask json.RawMessage `json:"finalmask"` + } `json:"streamSettings"` + } `json:"inbounds"` + Outbounds []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal([]byte(storedTemplate(t)), &template); err != nil || len(template.Outbounds) != 1 || len(template.Inbounds) != 1 { + t.Fatalf("stored template unreadable (%v)", err) + } + assertHopUpgraded(t, "template outbound", template.Outbounds[0].StreamSettings.Finalmask) + var inboundMask any + _ = json.Unmarshal(template.Inbounds[0].StreamSettings.Finalmask, &inboundMask) + var legacy any + _ = json.Unmarshal([]byte(legacyHopFinalmask), &legacy) + want, _ := json.Marshal(legacy) + if got, _ := json.Marshal(inboundMask); string(got) != string(want) { + t.Fatalf("template inbound finalmask = %s, want it untouched", got) + } + + var subMask model.Setting + if err := GetDB().Where("key = ?", "subJsonFinalMask").First(&subMask).Error; err != nil { + t.Fatalf("reload subJsonFinalMask: %v", err) + } + var subFinalmask any + _ = json.Unmarshal([]byte(subMask.Value), &subFinalmask) + assertHopUpgraded(t, "subJsonFinalMask", subFinalmask) + + var storedSub model.OutboundSubscription + if err := GetDB().First(&storedSub, sub.Id).Error; err != nil { + t.Fatalf("reload outbound subscription: %v", err) + } + var cached []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } + if err := json.Unmarshal([]byte(storedSub.LastFetchedOutbounds), &cached); err != nil || len(cached) != 1 { + t.Fatalf("cached subscription outbounds unreadable (%v)", err) + } + assertHopUpgraded(t, "cached subscription outbound", cached[0].StreamSettings.Finalmask) + + var stored model.Inbound + if err := GetDB().First(&stored, ib.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if stored.StreamSettings != inboundStream { + t.Fatalf("inbound stream = %s, want it untouched", stored.StreamSettings) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 991f2ea60..edcc85e33 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -16,6 +16,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" + "github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat" "github.com/mhsanaei/3x-ui/v3/internal/util/random" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -640,6 +641,8 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c if finalmask, ok := newStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = withLegacyFragmentRanges(finalmask) } + // An inbound keeps its hop range as quicParams.udpHop; clients hop only with the mask. + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) streamSettings, _ := json.MarshalIndent(newStream, "", " ") hostMux := hostMuxOverride(extPrxy) @@ -1001,6 +1004,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask) } + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) newStream["network"] = "hysteria" newStream["security"] = "tls" diff --git a/internal/sub/masque_test.go b/internal/sub/masque_test.go index 4486bdf89..58572b629 100644 --- a/internal/sub/masque_test.go +++ b/internal/sub/masque_test.go @@ -81,3 +81,36 @@ func TestGetInboundsBySubIdIncludesMasque(t *testing.T) { t.Fatalf("masque inbound not returned for subId: %+v", inbounds) } } + +// An inbound keeps its hop range in quicParams.udpHop (udphop refuses to run on a server), +// but the client config must carry the udphop mask: the core ignores the old key since 26.9.9. +func TestSubJsonServiceTurnsInboundHopIntoClientUDPHopMask(t *testing.T) { + inbound := &model.Inbound{ + Listen: "203.0.113.10", Port: 443, Protocol: model.Hysteria, Remark: "hy", + Settings: `{"version":2,"clients":[{"email":"ivy@example.test","auth":"a"}]}`, + StreamSettings: `{"network":"hysteria","security":"tls", + "hysteriaSettings":{"version":2}, + "tlsSettings":{"serverName":"hy.example.test","alpn":["h3"]}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}`, + } + client := model.Client{Email: "ivy@example.test", Auth: "a"} + + configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com") + if len(configs) != 1 { + t.Fatalf("got %d configs, want one", len(configs)) + } + var config struct { + Outbounds []struct { + Protocol string `json:"protocol"` + StreamSettings map[string]any `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(configs[0], &config); err != nil { + t.Fatalf("decode config: %v", err) + } + got, _ := json.Marshal(config.Outbounds[0].StreamSettings["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("client finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 5af07f658..6829e680d 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -769,6 +769,7 @@ func applyFinalMask(stream map[string]any, p url.Values) { if json.Unmarshal([]byte(fm), &parsed) == nil { sanitizeFinalMaskQuicParams(parsed) maskcompat.UpgradeLegacyXdns(parsed) + maskcompat.UpgradeLegacyUDPHop(parsed) stream["finalmask"] = parsed } } diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index 12dc34a46..bee390a7b 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -304,9 +304,9 @@ func TestParseHysteria2_Mport(t *testing.T) { wantHop: true, }, { - name: "legacy fm quicParams.udpHop no longer suppresses mport", + name: "legacy fm quicParams.udpHop is upgraded and wins over mport like the mask", query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), - wantPorts: "1-2", + wantPorts: "30000-40000", wantHop: true, }, } @@ -577,3 +577,22 @@ func TestParseShadowsocksObfsLocalPlugin(t *testing.T) { }) } } + +// A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop inside fm=, +// which the core ignores; imported verbatim, the outbound would never hop. +func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) { + fm := url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}`) + res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy") + if err != nil { + t.Fatalf("parse hysteria2 with fm: %v", err) + } + stream, _ := res.Outbound["streamSettings"].(map[string]any) + got, err := json.Marshal(stream["finalmask"]) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + want := `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/util/maskcompat/udphop.go b/internal/util/maskcompat/udphop.go new file mode 100644 index 000000000..f326f2621 --- /dev/null +++ b/internal/util/maskcompat/udphop.go @@ -0,0 +1,54 @@ +package maskcompat + +import "strings" + +// UpgradeLegacyUDPHop moves the quicParams.udpHop range xray-core 26.9.9 stopped reading +// into the client-only "udphop" UDP mask, whose intervalRemote mode is what the key did. +func UpgradeLegacyUDPHop(finalmask any) bool { + fm, _ := finalmask.(map[string]any) + quicParams, _ := fm["quicParams"].(map[string]any) + legacy, found := quicParams["udpHop"] + if !found { + return false + } + delete(quicParams, "udpHop") + if len(quicParams) == 0 { + delete(fm, "quicParams") + } + + hop, _ := legacy.(map[string]any) + ports := hop["ports"] + if isEmptyHopValue(ports) || hasUDPHopMask(fm) { + return true + } + settings := map[string]any{"mode": "intervalRemote", "remotePorts": ports} + if interval := hop["interval"]; !isEmptyHopValue(interval) { + settings["interval"] = interval + } + masks, _ := fm["udp"].([]any) + fm["udp"] = append(masks, map[string]any{"type": "udphop", "settings": settings}) + return true +} + +func hasUDPHopMask(fm map[string]any) bool { + masks, _ := fm["udp"].([]any) + for _, entry := range masks { + mask, _ := entry.(map[string]any) + if maskType, _ := mask["type"].(string); strings.EqualFold(maskType, "udphop") { + return true + } + } + return false +} + +func isEmptyHopValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + case float64: + return v == 0 + } + return false +} diff --git a/internal/util/maskcompat/udphop_test.go b/internal/util/maskcompat/udphop_test.go new file mode 100644 index 000000000..e8e7bab1a --- /dev/null +++ b/internal/util/maskcompat/udphop_test.go @@ -0,0 +1,106 @@ +package maskcompat + +import ( + "encoding/json" + "testing" + + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask/udphop" +) + +// coreUDPHop builds a finalmask's UDP masks through conf.Mask, the loader the core +// calls at startup, and returns the udphop config the client would hop with, if any. +func coreUDPHop(t *testing.T, finalmask map[string]any) *udphop.Config { + t.Helper() + raw, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + var fm conf.FinalMask + if err := json.Unmarshal(raw, &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + for _, mask := range fm.Udp { + built, err := mask.Build(false) + if err != nil { + t.Fatalf("the core refuses UDP mask %q: %v", mask.Type, err) + } + if hop, ok := built.(*udphop.Config); ok { + return hop + } + } + return nil +} + +func decodeFinalmask(t *testing.T, s string) map[string]any { + t.Helper() + var fm map[string]any + if err := json.Unmarshal([]byte(s), &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + return fm +} + +func TestUpgradeLegacyUDPHop(t *testing.T) { + fm := decodeFinalmask(t, `{"udp":[{"type":"salamander","settings":{"password":"p"}}], + "quicParams":{"congestion":"bbr","udpHop":{"ports":"20000-20003","interval":"5-10"}}}`) + if hop := coreUDPHop(t, fm); hop != nil { + t.Fatal("the core hops on quicParams.udpHop again; the upgrade is no longer needed") + } + + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change for a legacy hop") + } + got, _ := json.Marshal(fm) + const want = `{"quicParams":{"congestion":"bbr"},"udp":[{"settings":{"password":"p"},"type":"salamander"},{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-20003"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, want) + } + hop := coreUDPHop(t, fm) + if hop == nil || !hop.Remote || hop.Local || hop.IntervalMin != 5 || hop.IntervalMax != 10 || + len(hop.RemotePorts) != 4 || hop.RemotePorts[0] != 20000 || hop.RemotePorts[3] != 20003 { + t.Fatalf("the core hops with %+v, want remote ports 20000-20003 every 5-10s", hop) + } +} + +func TestUpgradeLegacyUDPHopEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "an explicit udphop mask wins over the dead key", + input: `{"udp":[{"type":"udphop","settings":{"mode":"perConnRemote","remotePorts":"443"}}],"quicParams":{"udpHop":{"ports":"20000-30000"}}}`, + want: `{"udp":[{"settings":{"mode":"perConnRemote","remotePorts":"443"},"type":"udphop"}]}`, + }, + { + name: "a hop without ports only loses the dead key", + input: `{"quicParams":{"udpHop":{"interval":"5-10"}}}`, + want: `{}`, + }, + { + name: "no interval keeps the core's 30s default", + input: `{"quicParams":{"udpHop":{"ports":"443,8443"}}}`, + want: `{"udp":[{"settings":{"mode":"intervalRemote","remotePorts":"443,8443"},"type":"udphop"}]}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fm := decodeFinalmask(t, tc.input) + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change") + } + got, _ := json.Marshal(fm) + if string(got) != tc.want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, tc.want) + } + coreUDPHop(t, fm) + }) + } + + current := decodeFinalmask(t, `{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"443"}}],"quicParams":{"congestion":"bbr"}}`) + if UpgradeLegacyUDPHop(current) { + t.Fatal("UpgradeLegacyUDPHop rewrote a finalmask without the legacy key") + } +} diff --git a/internal/web/service/inbound_finalmask_xdns_test.go b/internal/web/service/inbound_finalmask_xdns_test.go index fe1d0a049..7519f3017 100644 --- a/internal/web/service/inbound_finalmask_xdns_test.go +++ b/internal/web/service/inbound_finalmask_xdns_test.go @@ -113,3 +113,38 @@ func TestGetXrayConfig_UpgradesTemplateOutboundXdnsMask(t *testing.T) { } t.Fatal("outbound dns-tunnel not found in the generated config") } + +// A hysteria outbound pasted from a pre-26.9.9 template still carries quicParams.udpHop, +// which the core now ignores; the build heal hands it the udphop mask instead. +func TestGetXrayConfig_UpgradesTemplateOutboundUDPHop(t *testing.T) { + setupConflictDB(t) + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"hysteria","tag":"hy-out", + "settings":{"version":2,"address":"hy.example.com","port":443}, + "streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}}]}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + var outbounds []map[string]any + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatalf("decode emitted outbounds: %v", err) + } + for _, outbound := range outbounds { + if outbound["tag"] != "hy-out" { + continue + } + stream, _ := outbound["streamSettings"].(map[string]any) + got, _ := json.Marshal(stream["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("emitted finalmask\n got: %s\nwant: %s", got, want) + } + return + } + t.Fatal("outbound hy-out not found in the generated config") +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index edf986cf7..654d57c15 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { xrayConfig.API = ensureAPIServices(xrayConfig.API) xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy) xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig) - // A pasted or restored template can still carry pre-#6258 XHTTP session keys or a - // pre-26.10.10 xdns mask the core silently empties; heal them like the inbounds below. + // A pasted or restored template can still carry pre-#6258 XHTTP session keys, a pre-26.10.10 + // xdns mask or a pre-26.9.9 quicParams.udpHop, all of which the core silently ignores. xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs, - liftXhttpSessionIDKeys, upgradeStreamLegacyXdns) + liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop) // Bridge amneziawg outbounds before anything else reads OutboundConfigs; // the core has no amneziawg proxy and would reject the raw entry. if err := transformAmneziaWGOutbounds(xrayConfig); err != nil { @@ -1748,3 +1748,7 @@ func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[stri func upgradeStreamLegacyXdns(stream map[string]any) bool { return maskcompat.UpgradeLegacyXdns(stream["finalmask"]) } + +func upgradeStreamLegacyUDPHop(stream map[string]any) bool { + return maskcompat.UpgradeLegacyUDPHop(stream["finalmask"]) +}