Browse Source

fix(hysteria): standard geco share links and persistent uTLS None (#6325)

- Export standard gecko obfs query params in hysteria2 share links
- Enforce packet size bounds across Go and TypeScript link handlers
- Persist uTLS None explicitly and initialize new TLS inbounds to chrome
- Tear down stackTun safely without closeMu deadlock against WriteNotify

Co-authored-by: rqzbeh <[email protected]>
Rouzbeh† 9 hours ago
parent
commit
540caa4e93

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

@@ -12,6 +12,7 @@ import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-pro
 import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
 import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
 import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 
 
+import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { getHeaderValue } from './headers';
 import { getHeaderValue } from './headers';
 import { canEnableTlsFlow } from './protocol-capabilities';
 import { canEnableTlsFlow } from './protocol-capabilities';
 import { deriveSpiderX } from './spider-x';
 import { deriveSpiderX } from './spider-x';
@@ -437,7 +438,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     params.set('security', 'tls');
     params.set('security', 'tls');
     if (stream.security === 'tls') {
     if (stream.security === 'tls') {
       const tls = stream.tlsSettings;
       const tls = stream.tlsSettings;
-      params.set('fp', tls.settings.fingerprint);
+      if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
       params.set('alpn', tls.alpn.join(','));
       params.set('alpn', tls.alpn.join(','));
       if (tls.serverName.length > 0) params.set('sni', tls.serverName);
       if (tls.serverName.length > 0) params.set('sni', tls.serverName);
       if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
       if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
@@ -543,7 +544,7 @@ function writeTlsParams(
 ): void {
 ): void {
   if (stream.security !== 'tls') return;
   if (stream.security !== 'tls') return;
   const tls = stream.tlsSettings;
   const tls = stream.tlsSettings;
-  params.set('fp', tls.settings.fingerprint);
+  if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
   params.set('alpn', tls.alpn.join(','));
   params.set('alpn', tls.alpn.join(','));
   if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
   if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
   if (tls.serverName.length > 0) params.set('sni', tls.serverName);
   if (tls.serverName.length > 0) params.set('sni', tls.serverName);
@@ -801,13 +802,20 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     const salamander = udpMasks.find((m) => m?.type === 'salamander');
     const salamander = udpMasks.find((m) => m?.type === 'salamander');
     const obfsPassword = salamander?.settings?.password;
     const obfsPassword = salamander?.settings?.password;
     if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
     if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
-      params.set('obfs', 'salamander');
+      // packetSize (Gecko mode) exports via v2rayN's native fields; the
+      // experimental fm=<json> dump breaks mihomo and other strict clients.
+      const range = parseGeckoPacketSize(salamander?.settings?.packetSize);
+      if (range) {
+        params.set('obfs', 'gecko');
+        params.set('minPacketSize', String(range.min));
+        params.set('maxPacketSize', String(range.max));
+      } else {
+        params.set('obfs', 'salamander');
+      }
       params.set('obfs-password', obfsPassword);
       params.set('obfs-password', obfsPassword);
     }
     }
   }
   }
 
 
-  applyFinalMaskToParams(stream.finalmask, params);
-
   const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
   const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
   if (hopPorts.length > 0) {
   if (hopPorts.length > 0) {
     params.set('mport', hopPorts);
     params.set('mport', hopPorts);

+ 6 - 0
frontend/src/lib/xray/inbound-tls-defaults.ts

@@ -17,6 +17,12 @@ function defaultCertificate(): Record<string, unknown> {
 export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
 export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
   const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
   const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
   tls.certificates = [defaultCertificate()];
   tls.certificates = [defaultCertificate()];
+  const settings =
+    tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
+      ? { ...(tls.settings as Record<string, unknown>) }
+      : {};
+  settings.fingerprint = 'chrome';
+  tls.settings = settings;
   return tls;
   return tls;
 }
 }
 
 

+ 33 - 6
frontend/src/lib/xray/outbound-link-parser.ts

@@ -258,14 +258,34 @@ function ensureFinalMask(stream: Raw): Raw {
   return stream.finalmask as Raw;
   return stream.finalmask as Raw;
 }
 }
 
 
-// Rebuild the salamander mask from the standard Hysteria2 obfs pair (every
-// non-3x-ui client, and this panel's own generator, speak it instead of the
-// private fm=<json> dump). A salamander mask already carrying a password via fm=
-// wins; a password-less one is completed rather than left empty.
+// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
+// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
 function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
 function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
-  if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
+  const obfs = (params.get('obfs') ?? '').toLowerCase();
+  const isGecko = obfs === 'gecko';
+  if (!isGecko && obfs !== 'salamander') return;
   const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
   const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
   if (!password) return;
   if (!password) return;
+  let packetSize = '';
+  if (isGecko) {
+    // Both halves required and numeric, matching the export side; anything
+    // else is dropped rather than stored as a malformed range.
+    const minSize = (params.get('minPacketSize') ?? '').trim();
+    const maxSize = (params.get('maxPacketSize') ?? '').trim();
+    const min = Number(minSize);
+    const max = Number(maxSize);
+    if (
+      /^\d+$/.test(minSize) &&
+      /^\d+$/.test(maxSize) &&
+      Number.isSafeInteger(min) &&
+      Number.isSafeInteger(max) &&
+      min >= 1 &&
+      max >= min &&
+      max <= 2048
+    ) {
+      packetSize = `${min}-${max}`;
+    }
+  }
   const finalmask = ensureFinalMask(stream);
   const finalmask = ensureFinalMask(stream);
   const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
   const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
   const existing = udp.find(
   const existing = udp.find(
@@ -279,9 +299,16 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
     ) as Raw;
     ) as Raw;
     if (typeof settings.password !== 'string' || settings.password.length === 0)
     if (typeof settings.password !== 'string' || settings.password.length === 0)
       settings.password = password;
       settings.password = password;
+    if (
+      packetSize !== '' &&
+      !(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
+    )
+      settings.packetSize = packetSize;
     return;
     return;
   }
   }
-  finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
+  const settings: Raw = { password };
+  if (packetSize !== '') settings.packetSize = packetSize;
+  finalmask.udp = [...udp, { type: 'salamander', settings }];
 }
 }
 
 
 // Rebuild the UDP port-hopping range from the standard mport param, which the
 // Rebuild the UDP port-hopping range from the standard mport param, which the

+ 6 - 2
frontend/src/pages/hosts/HostFormModal.tsx

@@ -130,8 +130,12 @@ export default function HostFormModal({
     [],
     [],
   );
   );
   const fpOptions = useMemo(
   const fpOptions = useMemo(
-    () => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
-    [],
+    // '' = None first: Hysteria (and any no-uTLS host) must be selectable.
+    () => [
+      { value: '', label: t('none') },
+      ...Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
+    ],
+    [t],
   );
   );
 
 
   const hostOptions = useMemo(() => {
   const hostOptions = useMemo(() => {

+ 4 - 2
frontend/src/schemas/protocols/security/tls.ts

@@ -56,7 +56,9 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
 export type TlsCert = z.infer<typeof TlsCertSchema>;
 export type TlsCert = z.infer<typeof TlsCertSchema>;
 
 
 export const TlsClientSettingsSchema = z.object({
 export const TlsClientSettingsSchema = z.object({
-  fingerprint: TlsFingerprintSchema.default('chrome'),
+  // '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
+  // silently flipped the form's None back to chrome on every save.
+  fingerprint: TlsFingerprintSchema.default(''),
   echConfigList: z.string().default(''),
   echConfigList: z.string().default(''),
   pinnedPeerCertSha256: z.array(z.string()).default([]),
   pinnedPeerCertSha256: z.array(z.string()).default([]),
   // Panel-only client directive (v2rayN `vcn`): verify the server certificate
   // Panel-only client directive (v2rayN `vcn`): verify the server certificate
@@ -87,7 +89,7 @@ export const TlsStreamSettingsSchema = z.object({
   masterKeyLog: z.string().optional(),
   masterKeyLog: z.string().optional(),
   echSockopt: SockoptStreamSettingsSchema.optional(),
   echSockopt: SockoptStreamSettingsSchema.optional(),
   settings: TlsClientSettingsSchema.default({
   settings: TlsClientSettingsSchema.default({
-    fingerprint: 'chrome',
+    fingerprint: '',
     echConfigList: '',
     echConfigList: '',
     pinnedPeerCertSha256: [],
     pinnedPeerCertSha256: [],
     verifyPeerCertByName: '',
     verifyPeerCertByName: '',

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

@@ -808,3 +808,35 @@ describe('parseOutboundLink dispatcher', () => {
     expect(parseOutboundLink('   ')).toBeNull();
     expect(parseOutboundLink('   ')).toBeNull();
   });
   });
 });
 });
+
+describe('obfs=gecko packetSize validation', () => {
+  const base = 'hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw';
+
+  const packetSizeOf = (link: string): string | undefined => {
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as
+      | Record<string, unknown>
+      | undefined;
+    const udp = (finalmask?.udp ?? []) as Array<Record<string, unknown>>;
+    const mask = udp.find((m) => m.type === 'salamander');
+    return (mask?.settings as Record<string, unknown> | undefined)?.packetSize as
+      | string
+      | undefined;
+  };
+
+  it('stores a valid range', () => {
+    expect(packetSizeOf(`${base}&minPacketSize=512&maxPacketSize=1200`)).toBe('512-1200');
+  });
+
+  it.each([
+    ['min only', `${base}&minPacketSize=512`],
+    ['max only', `${base}&maxPacketSize=1200`],
+    ['non-numeric', `${base}&minPacketSize=abc&maxPacketSize=def`],
+    ['zero min', `${base}&minPacketSize=0&maxPacketSize=1200`],
+    ['inverted', `${base}&minPacketSize=1200&maxPacketSize=512`],
+    ['over cap', `${base}&minPacketSize=512&maxPacketSize=4096`],
+  ])('drops the %s range', (_name, link) => {
+    expect(packetSizeOf(link)).toBeUndefined();
+  });
+});

+ 77 - 0
frontend/src/test/tls-fingerprint-default.test.ts

@@ -0,0 +1,77 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
+import {
+  createTlsSettingsWithDefaultCert,
+  createHysteriaTlsSettingsWithDefaultCert,
+} from '@/lib/xray/inbound-tls-defaults';
+import { genHysteriaLink } from '@/lib/xray/inbound-link';
+import type { Inbound } from '@/schemas/api/inbound';
+
+// uTLS None ('') must survive a schema parse; the old default flipped it to
+// chrome on every save.
+describe('TlsClientSettingsSchema fingerprint default', () => {
+  it('parses an omitted fingerprint as None, not chrome', () => {
+    const parsed = TlsStreamSettingsSchema.parse({});
+    expect(parsed.settings.fingerprint).toBe('');
+  });
+
+  it('keeps an explicit empty-string fingerprint through parse', () => {
+    const parsed = TlsStreamSettingsSchema.parse({
+      settings: {
+        fingerprint: '',
+        echConfigList: '',
+        pinnedPeerCertSha256: [],
+        verifyPeerCertByName: '',
+      },
+    });
+    expect(parsed.settings.fingerprint).toBe('');
+  });
+
+  it('initializes generic TLS inbounds with chrome fingerprint default', () => {
+    const tls = createTlsSettingsWithDefaultCert();
+    expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('chrome');
+  });
+
+  it('initializes hysteria TLS inbounds with empty fingerprint default', () => {
+    const tls = createHysteriaTlsSettingsWithDefaultCert();
+    expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('');
+  });
+
+  it('does not inject fp into the hysteria share link when fingerprint is None', () => {
+    const raw = {
+      id: 1,
+      port: 443,
+      protocol: 'hysteria',
+      settings: { version: 2, clients: [{ auth: 'secret' }] },
+      streamSettings: {
+        security: 'tls',
+        tlsSettings: {
+          serverName: 'hy.test',
+          alpn: ['h3'],
+          settings: {
+            fingerprint: '',
+            echConfigList: '',
+            pinnedPeerCertSha256: [],
+            verifyPeerCertByName: '',
+          },
+        },
+        finalmask: {
+          udp: [{ type: 'salamander', settings: { password: 'pw', packetSize: '512-1200' } }],
+        },
+      },
+    };
+    const link = genHysteriaLink({
+      inbound: raw as unknown as Inbound,
+      address: 'example.test',
+      remark: 'gecko',
+      clientAuth: 'secret',
+    });
+    expect(link).toContain('obfs=gecko');
+    expect(link).toContain('minPacketSize=512');
+    expect(link).toContain('maxPacketSize=1200');
+    expect(link).not.toContain('fp=');
+    expect(link).not.toContain('fm=');
+  });
+});

+ 47 - 75
internal/amneziawgnet/netstack.go

@@ -1,19 +1,12 @@
-// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
-// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
-// the panel process, as an alternative to internal/amneziawg's
-// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
-// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
-// xray-core's own proxy/wireguard support) terminates each tunnel, and a
-// forwarder recovers each connection's real, dynamically-arbitrary
-// destination for the caller to relay onward (see Phase 2 of the migration
-// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
-// sniffing for free).
+// Package amneziawgnet embeds amneziawg-go and gVisor netstack in-process
+// as a userspace alternative to kernel wireguard / awg-quick.
 package amneziawgnet
 package amneziawgnet
 
 
 import (
 import (
 	"fmt"
 	"fmt"
 	"net/netip"
 	"net/netip"
 	"os"
 	"os"
+	"sync"
 	"syscall"
 	"syscall"
 
 
 	awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
 	awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
@@ -30,60 +23,39 @@ import (
 	"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
 	"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
 )
 )
 
 
-// tunQueueDepth is the outbound packet queue depth for both the gVisor
-// channel endpoint and the handoff channel to amneziawg-go's TUN reader
-// (see the stackTun literal in createNetTUNWithStack for why both need it).
+// tunQueueDepth is the outbound queue depth for channel endpoint and handoff.
 const tunQueueDepth = 1024
 const tunQueueDepth = 1024
 
 
-// stackTun implements amneziawg-go's tun.Device directly against a gVisor
-// channel endpoint, the same approach amneziawg-go's own tun/netstack
-// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
-// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
-// type keeps it unexported), so this is a local, from-source reimplementation
-// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
-// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
-// package needs.
+// stackTun implements amneziawg-go tun.Device over a gVisor channel endpoint,
+// exposing *stack.Stack for forwarder attachment.
 type stackTun struct {
 type stackTun struct {
 	ep             *channel.Endpoint
 	ep             *channel.Endpoint
 	stack          *stack.Stack
 	stack          *stack.Stack
 	events         chan awgtun.Event
 	events         chan awgtun.Event
 	notifyHandle   *channel.NotificationHandle
 	notifyHandle   *channel.NotificationHandle
 	incomingPacket chan *buffer.View
 	incomingPacket chan *buffer.View
+	done           chan struct{}
+	closeMu        sync.Mutex
+	closed         bool
 	mtu            int
 	mtu            int
 }
 }
 
 
-// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
-// local addresses (interface address(es), one per family) and returns the
-// underlying *stack.Stack alongside it so a caller can attach a forwarder
-// (see forwarder.go / udp.go).
+// createNetTUNWithStack builds a gVisor-backed tun.Device for localAddresses
+// and returns underlying *stack.Stack to attach forwarders.
 func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
 func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
 	opts := stack.Options{
 	opts := stack.Options{
 		NetworkProtocols:   []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
 		NetworkProtocols:   []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
 		TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
 		TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
-		// HandleLocal must stay false: promiscuous+spoofing mode (see
-		// forwarder.go) is what lets a destination other than the stack's
-		// own configured address reach the forwarder at all.
+		// HandleLocal stays false so non-local destinations reach forwarder.
 		HandleLocal: false,
 		HandleLocal: false,
 	}
 	}
 	dev := &stackTun{
 	dev := &stackTun{
-		// tunQueueDepth matches channel.New's own outbound queue depth
-		// below. WriteNotify (called synchronously from whatever gVisor
-		// goroutine is sending TCP data for the download/server->client
-		// direction) pushes into incomingPacket; RoutineReadFromTUN (a
-		// single amneziawg-go goroutine that encrypts and sends each
-		// packet over UDP) is the only reader. With no buffer, every
-		// outbound packet forced a full synchronous handoff between the
-		// two -- gVisor's sender blocked until the encrypt loop was ready
-		// for the next one, one packet at a time, no pipelining. The
-		// upload/client->server direction has no equivalent stall:
-		// Write->InjectInbound->DeliverNetworkPacket hands off into
-		// gVisor's own ~1MB per-connection TCP receive buffer and returns
-		// immediately. Buffering this channel gives the download
-		// direction the same slack the upload direction already had.
+		// tunQueueDepth buffers channel.New and incomingPacket for pipelining.
 		ep:             channel.New(tunQueueDepth, uint32(mtu), ""),
 		ep:             channel.New(tunQueueDepth, uint32(mtu), ""),
 		stack:          stack.New(opts),
 		stack:          stack.New(opts),
 		events:         make(chan awgtun.Event, 10),
 		events:         make(chan awgtun.Event, 10),
 		incomingPacket: make(chan *buffer.View, tunQueueDepth),
 		incomingPacket: make(chan *buffer.View, tunQueueDepth),
+		done:           make(chan struct{}),
 		mtu:            mtu,
 		mtu:            mtu,
 	}
 	}
 	sackEnabledOpt := tcpip.TCPSACKEnabled(true)
 	sackEnabledOpt := tcpip.TCPSACKEnabled(true)
@@ -132,25 +104,13 @@ func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
 func (t *stackTun) MTU() (int, error)           { return t.mtu, nil }
 func (t *stackTun) MTU() (int, error)           { return t.mtu, nil }
 func (t *stackTun) BatchSize() int              { return 1 }
 func (t *stackTun) BatchSize() int              { return 1 }
 
 
-// Read blocks for the first packet, then opportunistically drains any more
-// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
-// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
-// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
-// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
-// for a batch is already there. Without this drain loop, Read always
-// returned exactly one packet no matter how many buf could hold, so every
-// downstream step (peer lookup, per-peer staging, and ultimately the UDP
-// bind's own genuinely batched Send/sendmmsg) processed the download
-// direction one packet at a time while the upload direction's equivalent
-// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
-// over its whole buf) processed up to 128 per cycle. That asymmetry is
-// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
-// UDP bind support batching identically, only this Read implementation
-// didn't use it.
+// Read drains incomingPacket into buf, supporting batched reads.
 func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
 func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
-	view, ok := <-t.incomingPacket
-	if !ok {
+	var view *buffer.View
+	select {
+	case <-t.done:
 		return 0, os.ErrClosed
 		return 0, os.ErrClosed
+	case view = <-t.incomingPacket:
 	}
 	}
 	n, err := view.Read(buf[0][offset:])
 	n, err := view.Read(buf[0][offset:])
 	if err != nil {
 	if err != nil {
@@ -160,10 +120,7 @@ func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
 	count := 1
 	count := 1
 	for count < len(buf) {
 	for count < len(buf) {
 		select {
 		select {
-		case view, ok := <-t.incomingPacket:
-			if !ok {
-				return count, nil
-			}
+		case view = <-t.incomingPacket:
 			n, err := view.Read(buf[count][offset:])
 			n, err := view.Read(buf[count][offset:])
 			if err != nil {
 			if err != nil {
 				return count, nil
 				return count, nil
@@ -196,17 +153,41 @@ func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
 	return len(buf), nil
 	return len(buf), nil
 }
 }
 
 
+// WriteNotify runs on gVisor dispatch while Close tears the endpoint down,
+// so it must never block on closeMu across ep.Read or stack teardown.
 func (t *stackTun) WriteNotify() {
 func (t *stackTun) WriteNotify() {
+	t.closeMu.Lock()
+	if t.closed {
+		t.closeMu.Unlock()
+		return
+	}
+	t.closeMu.Unlock()
+
 	pkt := t.ep.Read()
 	pkt := t.ep.Read()
 	if pkt == nil {
 	if pkt == nil {
 		return
 		return
 	}
 	}
 	view := pkt.ToView()
 	view := pkt.ToView()
 	pkt.DecRef()
 	pkt.DecRef()
-	t.incomingPacket <- view
+
+	// Select against done so racing dispatch abandons packet on close
+	// without blocking Close or panicking on closed channel.
+	select {
+	case t.incomingPacket <- view:
+	case <-t.done:
+	}
 }
 }
 
 
 func (t *stackTun) Close() error {
 func (t *stackTun) Close() error {
+	t.closeMu.Lock()
+	if t.closed {
+		t.closeMu.Unlock()
+		return nil
+	}
+	t.closed = true
+	close(t.done)
+	t.closeMu.Unlock()
+
 	t.stack.RemoveNIC(1)
 	t.stack.RemoveNIC(1)
 	t.stack.Close()
 	t.stack.Close()
 	t.ep.RemoveNotify(t.notifyHandle)
 	t.ep.RemoveNotify(t.notifyHandle)
@@ -214,25 +195,16 @@ func (t *stackTun) Close() error {
 	if t.events != nil {
 	if t.events != nil {
 		close(t.events)
 		close(t.events)
 	}
 	}
-	if t.incomingPacket != nil {
-		close(t.incomingPacket)
-	}
 	return nil
 	return nil
 }
 }
 
 
-// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
-// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
-// packets addressed to a destination other than the stack's own configured
-// local address. Safe to call from both (and more than once): gVisor's
-// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
-// that accumulates or needs undoing between calls.
+// enablePromiscuousRouting configures NIC promiscuous and spoofing modes.
 func enablePromiscuousRouting(gstack *stack.Stack) {
 func enablePromiscuousRouting(gstack *stack.Stack) {
 	gstack.SetPromiscuousMode(1, true)
 	gstack.SetPromiscuousMode(1, true)
 	gstack.SetSpoofing(1, true)
 	gstack.SetSpoofing(1, true)
 }
 }
 
 
-// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
-// stdlib netip.Addr type the rest of this package and its callers use.
+// addrFromTcpip converts a gVisor tcpip.Address to netip.Addr.
 func addrFromTcpip(a tcpip.Address) netip.Addr {
 func addrFromTcpip(a tcpip.Address) netip.Addr {
 	if a.Len() == 4 {
 	if a.Len() == 4 {
 		var b [4]byte
 		var b [4]byte

+ 152 - 0
internal/sub/hysteria_geco_uri_test.go

@@ -0,0 +1,152 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+)
+
+// A salamander mask carrying packetSize (Gecko mode) must export the
+// v2rayN-native gecko URI fields, not an fm=<json> dump.
+func TestGenHysteriaLinkEmitsGeckoParamsForPacketSize(t *testing.T) {
+	in := &model.Inbound{
+		Id: 920001, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
+			`{"password":"pw","packetSize":"512-1200"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	for _, want := range []string{"obfs=gecko", "obfs-password=pw", "minPacketSize=512", "maxPacketSize=1200"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("missing %q\n got: %s", want, got)
+		}
+	}
+	if strings.Contains(got, "obfs=salamander") {
+		t.Fatalf("gecko mask exported as plain salamander:\n %s", got)
+	}
+	if strings.Contains(got, "fm=") {
+		t.Fatalf("expressed salamander mask must not leak into fm= dump:\n %s", got)
+	}
+}
+
+// Password-only masks keep the plain salamander export.
+func TestGenHysteriaLinkSalamanderWithoutPacketSizeUnchanged(t *testing.T) {
+	in := &model.Inbound{
+		Id: 920002, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings:       `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	if !strings.Contains(got, "obfs=salamander") || !strings.Contains(got, "obfs-password=pw") {
+		t.Fatalf("password-only mask lost its standard export:\n %s", got)
+	}
+	for _, bad := range []string{"minPacketSize=", "maxPacketSize="} {
+		if strings.Contains(got, bad) {
+			t.Fatalf("unexpected %s in:\n %s", bad, got)
+		}
+	}
+}
+
+// Import side: obfs=gecko + min/max rebuild a standard salamander+packetSize mask.
+func TestParseLinkAcceptsGeckoObfs(t *testing.T) {
+	parsed, err := link.ParseLink(
+		"hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=1200#geo")
+	if err != nil {
+		t.Fatalf("ParseLink: %v", err)
+	}
+	rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
+	if rawStream == nil {
+		t.Fatalf("no streamSettings in outbound: %v", parsed.Outbound)
+	}
+	streamJSON, err := json.Marshal(rawStream)
+	if err != nil {
+		t.Fatalf("marshal stream: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal(streamJSON, &stream); err != nil {
+		t.Fatalf("stream json: %v", err)
+	}
+	fm, _ := stream["finalmask"].(map[string]any)
+	if fm == nil {
+		t.Fatalf("no finalmask rebuilt: %s", streamJSON)
+	}
+	udp, _ := fm["udp"].([]any)
+	var mask map[string]any
+	for _, m := range udp {
+		if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
+			mask = mm
+		}
+	}
+	if mask == nil {
+		t.Fatalf("no salamander mask rebuilt: %s", streamJSON)
+	}
+	settings, _ := mask["settings"].(map[string]any)
+	if pw, _ := settings["password"].(string); pw != "pw" {
+		t.Fatalf("password = %v", settings["password"])
+	}
+	if ps, _ := settings["packetSize"].(string); ps != "512-1200" {
+		t.Fatalf("packetSize = %v, want 512-1200", settings["packetSize"])
+	}
+}
+
+// Half-specified or out-of-bounds gecko ranges must be dropped, not stored.
+func TestParseLinkRejectsInvalidGeckoPacketSize(t *testing.T) {
+	cases := map[string]string{
+		"half min only": "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512#geo",
+		"half max only": "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&maxPacketSize=1200#geo",
+		"non-numeric":   "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=abc&maxPacketSize=def#geo",
+		"zero min":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=0&maxPacketSize=1200#geo",
+		"inverted":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512#geo",
+		"over cap":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=4096#geo",
+	}
+	for name, uri := range cases {
+		t.Run(name, func(t *testing.T) {
+			parsed, err := link.ParseLink(uri)
+			if err != nil {
+				t.Fatalf("ParseLink: %v", err)
+			}
+			rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
+			streamJSON, _ := json.Marshal(rawStream)
+			var stream map[string]any
+			_ = json.Unmarshal(streamJSON, &stream)
+			fm, _ := stream["finalmask"].(map[string]any)
+			if fm == nil {
+				t.Fatalf("no finalmask rebuilt: %s", streamJSON)
+			}
+			udp, _ := fm["udp"].([]any)
+			for _, m := range udp {
+				if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
+					settings, _ := mm["settings"].(map[string]any)
+					if ps, _ := settings["packetSize"].(string); ps != "" {
+						t.Fatalf("invalid gecko stored packetSize %q", ps)
+					}
+				}
+			}
+		})
+	}
+}
+
+// Export side must mirror the TS bounds exactly (1 <= min <= max <= 2048).
+func TestParseHysteriaPacketSizeBounds(t *testing.T) {
+	if got := parseHysteriaPacketSize("0-1200"); got != "" {
+		t.Fatalf("min below 1 accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize("1200-512"); got != "" {
+		t.Fatalf("inverted range accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize("512-4096"); got != "" {
+		t.Fatalf("range over xray cap accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize(" 512 - 1200 "); got != "" {
+		t.Fatalf("padded range must be rejected: %q", got)
+	}
+	if got := parseHysteriaPacketSize("+512-1200"); got != "" {
+		t.Fatalf("plus-prefixed range must be rejected: %q", got)
+	}
+	if got := parseHysteriaPacketSize("512-1200"); got != "512-1200" {
+		t.Fatalf("valid range = %q", got)
+	}
+}

+ 6 - 1
internal/sub/remote_routing.go

@@ -66,6 +66,7 @@ type remoteRoutingFetch struct {
 }
 }
 
 
 type remoteRoutingResolver struct {
 type remoteRoutingResolver struct {
+	refreshWG    sync.WaitGroup
 	mu           sync.Mutex
 	mu           sync.Mutex
 	loadMu       sync.Mutex
 	loadMu       sync.Mutex
 	loaded       bool
 	loaded       bool
@@ -154,7 +155,11 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
 	r.inflight[key] = fetch
 	r.inflight[key] = fetch
 	r.mu.Unlock()
 	r.mu.Unlock()
 
 
-	common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
+	r.refreshWG.Add(1)
+	common.GoRecover("remote-routing-refresh", func() {
+		defer r.refreshWG.Done()
+		r.refresh(key, cached, hasCached, fetch)
+	})
 	if hasCached {
 	if hasCached {
 		return cached, true, nil
 		return cached, true, nil
 	}
 	}

+ 2 - 0
internal/sub/remote_routing_test.go

@@ -47,6 +47,8 @@ func remoteRoutingResponse(status int, body string) *http.Response {
 
 
 func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
 func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
 	t.Helper()
 	t.Helper()
+	// Wait on refresh goroutines to prevent logging race after test teardown.
+	resolver.refreshWG.Wait()
 	deadline := time.Now().Add(2 * time.Second)
 	deadline := time.Now().Add(2 * time.Second)
 	for {
 	for {
 		resolver.mu.Lock()
 		resolver.mu.Lock()

+ 37 - 4
internal/sub/salamander_uri_test.go

@@ -11,13 +11,22 @@ import (
 )
 )
 
 
 func TestExtraSalamanderKeys(t *testing.T) {
 func TestExtraSalamanderKeys(t *testing.T) {
-	if got := extraSalamanderKeys(map[string]any{"password": "pw"}); len(got) != 0 {
+	if got := extraSalamanderKeys(map[string]any{"password": "pw"}, false); len(got) != 0 {
 		t.Fatalf("expressible settings reported extras: %v", got)
 		t.Fatalf("expressible settings reported extras: %v", got)
 	}
 	}
-	got := extraSalamanderKeys(map[string]any{"password": "pw", "packetSize": "512-1200"})
-	if want := []string{"packetSize"}; !reflect.DeepEqual(got, want) {
+	// packetSize exports as the v2rayN gecko fields when expressed; a truly
+	// unexpressible key always is. An inexpressible packetSize stays extra.
+	in := map[string]any{"password": "pw", "headerType": "dns"}
+	if got, want := extraSalamanderKeys(in, false), []string{"headerType"}; !reflect.DeepEqual(got, want) {
 		t.Fatalf("extraSalamanderKeys = %v, want %v", got, want)
 		t.Fatalf("extraSalamanderKeys = %v, want %v", got, want)
 	}
 	}
+	full := map[string]any{"password": "pw", "packetSize": "512-1200", "headerType": "dns"}
+	if got, want := extraSalamanderKeys(full, true), []string{"headerType"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("expressed packetSize not excluded: %v, want %v", got, want)
+	}
+	if got, want := extraSalamanderKeys(full, false), []string{"headerType", "packetSize"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("unexpressed packetSize not reported: %v, want %v", got, want)
+	}
 }
 }
 
 
 func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
 func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
@@ -46,10 +55,34 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T)
 	}
 	}
 
 
 	const unsupportedID = 910002
 	const unsupportedID = 910002
-	in := makeInbound(unsupportedID, `{"password":"pw","packetSize":"512-1200"}`)
+	in := makeInbound(unsupportedID, `{"password":"pw","headerType":"dns"}`)
 	(&SubService{}).genHysteriaLink(in, "user")
 	(&SubService{}).genHysteriaLink(in, "user")
 	(&SubService{}).genHysteriaLink(in, "user")
 	(&SubService{}).genHysteriaLink(in, "user")
 	if got := countWarnings(unsupportedID); got != 1 {
 	if got := countWarnings(unsupportedID); got != 1 {
 		t.Fatalf("unsupported-settings warning count = %d, want 1", got)
 		t.Fatalf("unsupported-settings warning count = %d, want 1", got)
 	}
 	}
 }
 }
+
+// A mask with BOTH an expressible packetSize and another key must still warn
+// about the leftover key while emitting the gecko URI.
+func TestGenHysteriaLinkGeckoStillWarnsOnExtraKeys(t *testing.T) {
+	in := &model.Inbound{
+		Id: 910003, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings:       `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw","packetSize":"512-1200","headerType":"dns"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	if !strings.Contains(got, "obfs=gecko") {
+		t.Fatalf("gecko not emitted for valid packetSize:\n %s", got)
+	}
+	needle := "inbound 910003: salamander settings"
+	found := 0
+	for _, line := range logger.GetLogs(100, "warning") {
+		if strings.Contains(line, needle) {
+			found++
+		}
+	}
+	if found == 0 {
+		t.Fatal("leftover salamander key did not warn alongside the gecko export")
+	}
+}

+ 58 - 10
internal/sub/service.go

@@ -1176,9 +1176,8 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 		}
 	}
 	}
 
 
-	// salamander obfs (Hysteria2). Emit only the standard URI fields;
-	// the non-standard fm=<json> finalmask dump breaks mihomo and other
-	// Hysteria2 clients that reject unknown query params.
+	// salamander obfs (Hysteria2): standard URI fields only -- an fm=<json>
+	// dump breaks strict clients. packetSize exports as v2rayN's gecko pair.
 	if finalmask, ok := stream["finalmask"].(map[string]any); ok {
 	if finalmask, ok := stream["finalmask"].(map[string]any); ok {
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 			for _, m := range udpMasks {
 			for _, m := range udpMasks {
@@ -1188,13 +1187,23 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 				}
 				}
 				settings, _ := mask["settings"].(map[string]any)
 				settings, _ := mask["settings"].(map[string]any)
 				if pw, ok := settings["password"].(string); ok && pw != "" {
 				if pw, ok := settings["password"].(string); ok && pw != "" {
-					if extra := extraSalamanderKeys(settings); len(extra) > 0 {
+					packetSize, _ := settings["packetSize"].(string)
+					gecko := parseHysteriaPacketSize(packetSize)
+					if gecko != "" {
+						params["obfs"] = "gecko"
+						params["minPacketSize"], params["maxPacketSize"] = splitHysteriaPacketSize(gecko)
+					}
+					// packetSize rides its own URI fields; anything else still
+					// breaks standard clients and must warn even when gecko fires.
+					if extra := extraSalamanderKeys(settings, gecko != ""); len(extra) > 0 {
 						warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra)
 						warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra)
 						if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded {
 						if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded {
 							logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra)
 							logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra)
 						}
 						}
 					}
 					}
-					params["obfs"] = "salamander"
+					if params["obfs"] == "" {
+						params["obfs"] = "salamander"
+					}
 					params["obfs-password"] = pw
 					params["obfs-password"] = pw
 					break
 					break
 				}
 				}
@@ -1260,6 +1269,44 @@ func hysteriaHopPorts(stream map[string]any) string {
 	return strings.TrimSpace(ports)
 	return strings.TrimSpace(ports)
 }
 }
 
 
+// gecko packetSize bounds mirror xray-core's salamander buffer cap and the
+// frontend editor, so both link generators emit identical URIs.
+const (
+	geckoMinPacketSize = 1
+	geckoMaxPacketSize = 2048
+)
+
+// parseHysteriaPacketSize validates an xray-core salamander packetSize range
+// ("512-1200", the Gecko obfs marker). Returns canonical "min-max" or "".
+func parseHysteriaPacketSize(value string) string {
+	minStr, maxStr, ok := strings.Cut(value, "-")
+	if !ok || minStr == "" || maxStr == "" {
+		return ""
+	}
+	for _, c := range minStr {
+		if c < '0' || c > '9' {
+			return ""
+		}
+	}
+	for _, c := range maxStr {
+		if c < '0' || c > '9' {
+			return ""
+		}
+	}
+	minVal, err1 := strconv.Atoi(minStr)
+	maxVal, err2 := strconv.Atoi(maxStr)
+	if err1 != nil || err2 != nil ||
+		minVal < geckoMinPacketSize || maxVal < minVal || maxVal > geckoMaxPacketSize {
+		return ""
+	}
+	return fmt.Sprintf("%d-%d", minVal, maxVal)
+}
+
+func splitHysteriaPacketSize(value string) (string, string) {
+	minStr, maxStr, _ := strings.Cut(value, "-")
+	return minStr, maxStr
+}
+
 // loadNodes refreshes nodesByID from the DB. Called once per request so
 // loadNodes refreshes nodesByID from the DB. Called once per request so
 // the per-inbound resolveInboundAddress lookups are pure map reads.
 // the per-inbound resolveInboundAddress lookups are pure map reads.
 // We filter to address != ” so a half-configured node row doesn't
 // We filter to address != ” so a half-configured node row doesn't
@@ -2843,14 +2890,15 @@ func getHostFromXFH(s string) (string, error) {
 	return s, nil
 	return s, nil
 }
 }
 
 
-// extraSalamanderKeys lists salamander settings the hysteria2 URI cannot carry.
-// A server using them rejects every client built from the emitted link.
-func extraSalamanderKeys(settings map[string]any) []string {
+// extraSalamanderKeys lists salamander settings unexpressible in hysteria2 URI;
+// a server using any reported key rejects clients built from the link.
+func extraSalamanderKeys(settings map[string]any, expressedPacketSize bool) []string {
 	var extra []string
 	var extra []string
 	for k := range settings {
 	for k := range settings {
-		if k != "password" {
-			extra = append(extra, k)
+		if k == "password" || (k == "packetSize" && expressedPacketSize) {
+			continue
 		}
 		}
+		extra = append(extra, k)
 	}
 	}
 	sort.Strings(extra)
 	sort.Strings(extra)
 	return extra
 	return extra

+ 41 - 6
internal/util/link/outbound.go

@@ -688,19 +688,45 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 	}
 	}
 }
 }
 
 
+// gecko packetSize bounds mirror xray-core's salamander buffer cap.
+const (
+	geckoMinPacketSize = 1
+	geckoMaxPacketSize = 2048
+)
+
+// parsePacketSizeRange validates a min/max pair for the Gecko obfs marker.
+func parsePacketSizeRange(minStr, maxStr string) (int, int, bool) {
+	minVal, err1 := strconv.Atoi(minStr)
+	maxVal, err2 := strconv.Atoi(maxStr)
+	if err1 != nil || err2 != nil ||
+		minVal < geckoMinPacketSize || maxVal < minVal || maxVal > geckoMaxPacketSize {
+		return 0, 0, false
+	}
+	return minVal, maxVal, true
+}
+
 // applyHysteria2Obfs rebuilds the salamander mask from the standard Hysteria2
 // applyHysteria2Obfs rebuilds the salamander mask from the standard Hysteria2
-// obfs=salamander & obfs-password=<pw> pair (every non-3x-ui client, and this
-// panel's own generator, speak it instead of the private fm=<json> dump). A
-// salamander mask already carrying a password via fm= wins; a password-less one
-// is completed rather than left empty.
+// obfs pair. An fm=-carried password wins; gecko adds the packetSize pair.
 func applyHysteria2Obfs(stream map[string]any, p url.Values) {
 func applyHysteria2Obfs(stream map[string]any, p url.Values) {
-	if !strings.EqualFold(p.Get("obfs"), "salamander") {
+	obfs := p.Get("obfs")
+	isGecko := strings.EqualFold(obfs, "gecko")
+	if !isGecko && !strings.EqualFold(obfs, "salamander") {
 		return
 		return
 	}
 	}
 	password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword")
 	password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword")
 	if password == "" {
 	if password == "" {
 		return
 		return
 	}
 	}
+	packetSize := ""
+	if isGecko {
+		// Both halves required with digit+range validation, matching the
+		// export side; half-specified or non-numeric values are dropped.
+		minSize := strings.TrimSpace(p.Get("minPacketSize"))
+		maxSize := strings.TrimSpace(p.Get("maxPacketSize"))
+		if min, max, ok := parsePacketSizeRange(minSize, maxSize); ok {
+			packetSize = fmt.Sprintf("%d-%d", min, max)
+		}
+	}
 	finalmask := ensureChildMap(stream, "finalmask")
 	finalmask := ensureChildMap(stream, "finalmask")
 	udp, _ := finalmask["udp"].([]any)
 	udp, _ := finalmask["udp"].([]any)
 	for _, m := range udp {
 	for _, m := range udp {
@@ -716,11 +742,20 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) {
 		if pw, _ := settings["password"].(string); pw == "" {
 		if pw, _ := settings["password"].(string); pw == "" {
 			settings["password"] = password
 			settings["password"] = password
 		}
 		}
+		if packetSize != "" {
+			if ps, _ := settings["packetSize"].(string); ps == "" {
+				settings["packetSize"] = packetSize
+			}
+		}
 		return
 		return
 	}
 	}
+	settings := map[string]any{"password": password}
+	if packetSize != "" {
+		settings["packetSize"] = packetSize
+	}
 	finalmask["udp"] = append(udp, map[string]any{
 	finalmask["udp"] = append(udp, map[string]any{
 		"type":     "salamander",
 		"type":     "salamander",
-		"settings": map[string]any{"password": password},
+		"settings": settings,
 	})
 	})
 }
 }