From dab0add191a17a54a2018d3028a649edfa4d6a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:21:31 +0200 Subject: [PATCH] feat(finalmask): support Salamander packetSize (Gecko) and Realm tlsConfig for Hysteria2 (#5278) * feat(finalmask): support salamander packetSize (Gecko) and realm tlsConfig Hysteria v2.9.1/v2.9.2 added two finalmask features that the pinned Xray-core (26.6.1, 94ffd50) already supports but the panel UI did not expose: Salamander's packetSize range (Gecko, XTLS/Xray-core#6198) and the Realm UDP hole-punching mask's optional tlsConfig (XTLS/Xray-core#6137). Add typed schemas and form fields for both, keeping UdpMaskSchema.settings permissive per the existing finalmask design note. packetSize reuses the existing dash-range preprocess (like udpHop.ports) so it round-trips under the fm= share-link param with no new URI key; realm tlsConfig emits xray's flat TLSConfig shape (serverName/alpn/fingerprint/allowInsecure). Verified against the bundled Xray 26.6.1: configs with packetSize and realm tlsConfig validate (Configuration OK.), plain salamander stays backward-compatible, and a malformed packetSize is correctly rejected by the salamander mask builder. Co-Authored-By: Claude Opus 4.8 (1M context) * test(finalmask): add snapshots for salamander-gecko and realm-tls fixtures vitest run does not auto-create missing snapshots in CI mode, so the two new fixtures need committed snapshot entries. Verified under node:22 that finalmask.test.ts passes (6/6) with these snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(finalmask): polished Gecko UX with core-grounded validation Fold PR #5281's Gecko work into the Realm tlsConfig base: - Replace the plain packetSize input with a Salamander/Gecko mode selector and validated Min/Max number inputs. - parseGeckoPacketSize enforces xray-core's real bound (1 <= min <= max <= 2048, the gecko buffer size) so the panel rejects configs core would reject at runtime. - Accurate Gecko description; add parser unit tests. - Drop the unused Salamander/Realm settings schemas; settings stay permissive and are validated at the form level. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Sanaei --- .../xray/forms/transport/FinalMaskForm.tsx | 195 ++++++++++++++++-- .../test/__snapshots__/finalmask.test.ts.snap | 40 ++++ frontend/src/test/finalmask.test.ts | 20 ++ .../golden/fixtures/finalmask/realm-tls.json | 17 ++ .../fixtures/finalmask/salamander-gecko.json | 5 + .../src/test/outbound-link-parser.test.ts | 40 ++++ 6 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 frontend/src/test/golden/fixtures/finalmask/realm-tls.json create mode 100644 frontend/src/test/golden/fixtures/finalmask/salamander-gecko.json diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 83e3154d2..537cddd89 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -4,7 +4,9 @@ import type { FormInstance } from 'antd/es/form'; import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; -import { OutboundProtocols } from '@/schemas/primitives'; +import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives'; + +const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value })); export interface FinalMaskFormProps { name: NamePath; @@ -18,6 +20,46 @@ export interface FinalMaskFormProps { } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; +const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 }; +// Xray-core caps the Gecko output packet size at its internal buffer (2048) +// and needs 1 <= min <= max; mirror those bounds so the panel rejects what +// core would reject at runtime (salamander/conn.go). +const GECKO_MIN_PACKET_SIZE = 1; +const GECKO_MAX_PACKET_SIZE = 2048; + +export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null { + const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + const match = /^(\d+)-(\d+)$/.exec(str); + if (!match) return null; + const min = Number(match[1]); + const max = Number(match[2]); + if ( + !Number.isSafeInteger(min) || !Number.isSafeInteger(max) + || min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE + ) { + return null; + } + return { min, max }; +} + +function formatGeckoPacketSize(min: number, max: number): string { + return `${min}-${max}`; +} + +function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } { + const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + const [minRaw = '', maxRaw = ''] = str.split('-', 2); + const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null; + const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null; + return { min, max }; +} + +function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise { + if (parseGeckoPacketSize(value)) return Promise.resolve(); + return Promise.reject(new Error( + `Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`, + )); +} function asPath(name: NamePath): (string | number)[] { return Array.isArray(name) ? [...name] : [name]; @@ -470,22 +512,7 @@ function UdpMaskItem({ {({ getFieldValue }) => { const type = getFieldValue([...absolutePath, 'type']) as string | undefined; if (type === 'salamander') { - return ( - - - - - -