Quellcode durchsuchen

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) <[email protected]>

* 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) <[email protected]>

* 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) <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Rouzbeh† vor 10 Stunden
Ursprung
Commit
dab0add191

+ 178 - 17
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<void> {
+  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 (
-              <Form.Item label="Password">
-                <Space.Compact block>
-                  <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
-                    <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
-                  </Form.Item>
-                  <Button
-                    icon={<ReloadOutlined />}
-                    onClick={() => form.setFieldValue(
-                      [...absolutePath, 'settings', 'password'],
-                      RandomUtil.randomLowerAndNum(16),
-                    )}
-                  />
-                </Space.Compact>
-              </Form.Item>
-            );
+            return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
           }
           if (type === 'mkcp-legacy') {
             return (
@@ -537,6 +564,35 @@ function UdpMaskItem({
                 <Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
                   <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
                 </Form.Item>
+                <Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
+                <Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
+                  <Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
+                </Form.Item>
+                <Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
+                  <Select
+                    mode="multiple"
+                    style={{ width: '100%' }}
+                    options={[
+                      { value: 'h3', label: 'h3' },
+                      { value: 'h2', label: 'h2' },
+                      { value: 'http/1.1', label: 'http/1.1' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
+                  <Select
+                    allowClear
+                    style={{ width: '100%' }}
+                    options={UTLS_FINGERPRINT_OPTIONS}
+                  />
+                </Form.Item>
+                <Form.Item
+                  label="Allow Insecure"
+                  name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
               </>
             );
           }
@@ -565,6 +621,111 @@ function UdpMaskItem({
   );
 }
 
+function SalamanderUdpMaskSettings({
+  fieldName, form, absolutePath,
+}: {
+  fieldName: number;
+  form: FormInstance;
+  absolutePath: (string | number)[];
+}) {
+  const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
+  const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
+  const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
+
+  return (
+    <>
+      <Form.Item
+        label="Mode"
+        extra={mode === 'gecko'
+          ? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
+          : 'Scrambles each packet into random-looking bytes.'}
+      >
+        <Select
+          value={mode}
+          onChange={(next) => {
+            if (next === 'gecko') {
+              const current = form.getFieldValue(packetSizePath);
+              form.setFieldValue(
+                packetSizePath,
+                parseGeckoPacketSize(current)
+                  ? current
+                  : formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
+              );
+            } else {
+              form.setFieldValue(packetSizePath, undefined);
+            }
+          }}
+          options={[
+            { value: 'salamander', label: 'Salamander' },
+            { value: 'gecko', label: 'Gecko experimental' },
+          ]}
+        />
+      </Form.Item>
+
+      <Form.Item label="Password">
+        <Space.Compact block>
+          <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
+            <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
+          </Form.Item>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={() => form.setFieldValue(
+              [...absolutePath, 'settings', 'password'],
+              RandomUtil.randomLowerAndNum(16),
+            )}
+          />
+        </Space.Compact>
+      </Form.Item>
+
+      {mode === 'gecko' && (
+        <Form.Item
+          label="Packet size"
+          name={[fieldName, 'settings', 'packetSize']}
+          rules={[{ validator: validateGeckoPacketSize }]}
+          extra="Serialized as a string range, for example 512-1200."
+        >
+          <GeckoPacketSizeInput />
+        </Form.Item>
+      )}
+    </>
+  );
+}
+
+function GeckoPacketSizeInput({
+  value,
+  onChange,
+}: {
+  value?: string;
+  onChange?: (value: string) => void;
+}) {
+  const { min, max } = splitGeckoPacketSize(value);
+
+  return (
+    <Space.Compact block>
+      <InputNumber
+        addonBefore="Min"
+        min={GECKO_MIN_PACKET_SIZE}
+        max={GECKO_MAX_PACKET_SIZE}
+        precision={0}
+        value={min}
+        placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
+        onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
+        style={{ width: '50%' }}
+      />
+      <InputNumber
+        addonBefore="Max"
+        min={GECKO_MIN_PACKET_SIZE}
+        max={GECKO_MAX_PACKET_SIZE}
+        precision={0}
+        value={max}
+        placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
+        onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
+        style={{ width: '50%' }}
+      />
+    </Space.Compact>
+  );
+}
+
 function UdpHeaderCustom({
   udpFieldName, form, absoluteSettingsPath,
 }: {

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

@@ -61,6 +61,46 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
 }
 `;
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "stunServers": [
+          "stun.l.google.com:19302",
+        ],
+        "tlsConfig": {
+          "allowInsecure": false,
+          "alpn": [
+            "h3",
+          ],
+          "fingerprint": "chrome",
+          "serverName": "example.com",
+        },
+        "url": "realm://[email protected]/my-realm",
+      },
+      "type": "realm",
+    },
+  ],
+}
+`;
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "packetSize": "100-200",
+        "password": "swordfish",
+      },
+      "type": "salamander",
+    },
+  ],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
 {
   "tcp": [

+ 20 - 0
frontend/src/test/finalmask.test.ts

@@ -1,6 +1,7 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
+import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
 
 const fixtures = import.meta.glob<unknown>(
@@ -24,3 +25,22 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
     });
   }
 });
+
+describe('parseGeckoPacketSize', () => {
+  it('accepts positive ordered packet size ranges', () => {
+    expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });
+    expect(parseGeckoPacketSize('1200-1200')).toEqual({ min: 1200, max: 1200 });
+    expect(parseGeckoPacketSize('1-2048')).toEqual({ min: 1, max: 2048 });
+  });
+
+  it('rejects invalid packet size ranges', () => {
+    expect(parseGeckoPacketSize('')).toBeNull();
+    expect(parseGeckoPacketSize('0-1200')).toBeNull();
+    expect(parseGeckoPacketSize('1200-512')).toBeNull();
+    expect(parseGeckoPacketSize('512')).toBeNull();
+    expect(parseGeckoPacketSize('512-abc')).toBeNull();
+    // exceeds xray-core's gecko buffer (max 2048)
+    expect(parseGeckoPacketSize('512-2049')).toBeNull();
+    expect(parseGeckoPacketSize('512-9999')).toBeNull();
+  });
+});

+ 17 - 0
frontend/src/test/golden/fixtures/finalmask/realm-tls.json

@@ -0,0 +1,17 @@
+{
+  "udp": [
+    {
+      "type": "realm",
+      "settings": {
+        "url": "realm://[email protected]/my-realm",
+        "stunServers": ["stun.l.google.com:19302"],
+        "tlsConfig": {
+          "serverName": "example.com",
+          "allowInsecure": false,
+          "alpn": ["h3"],
+          "fingerprint": "chrome"
+        }
+      }
+    }
+  ]
+}

+ 5 - 0
frontend/src/test/golden/fixtures/finalmask/salamander-gecko.json

@@ -0,0 +1,5 @@
+{
+  "udp": [
+    { "type": "salamander", "settings": { "password": "swordfish", "packetSize": "100-200" } }
+  ]
+}

+ 40 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -288,6 +288,46 @@ describe('parseHysteria2Link', () => {
     expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
   });
 
+  it('round-trips the salamander packetSize (Gecko) under fm', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],
+    }));
+    const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-gecko`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(udp[0].type).toBe('salamander');
+    expect(settings.password).toBe('ftwfgb9655hh2mgo');
+    expect(settings.packetSize).toBe('100-200');
+  });
+
+  it('round-trips the realm tlsConfig under fm', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{
+        type: 'realm',
+        settings: {
+          url: 'realm://[email protected]/my-realm',
+          stunServers: ['stun.l.google.com:19302'],
+          tlsConfig: { serverName: 'example.com', alpn: ['h3'], fingerprint: 'chrome', allowInsecure: false },
+        },
+      }],
+    }));
+    const link = `hysteria2://auth@srv:443?security=tls&sni=srv&fm=${fm}#hy2-realm`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(udp[0].type).toBe('realm');
+    expect(settings.url).toBe('realm://[email protected]/my-realm');
+    const tlsConfig = settings.tlsConfig as Record<string, unknown>;
+    expect(tlsConfig.serverName).toBe('example.com');
+    expect(tlsConfig.alpn).toEqual(['h3']);
+    expect(tlsConfig.fingerprint).toBe('chrome');
+  });
+
   it('defaults alpn to h3 when the link omits it', () => {
     const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
     const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;