From 540caa4e935801c11fb6afda38e879eee9d312bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:05:07 +0330 Subject: [PATCH] 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 --- frontend/src/lib/xray/inbound-link.ts | 18 ++- frontend/src/lib/xray/inbound-tls-defaults.ts | 6 + frontend/src/lib/xray/outbound-link-parser.ts | 39 ++++- frontend/src/pages/hosts/HostFormModal.tsx | 8 +- .../src/schemas/protocols/security/tls.ts | 6 +- .../src/test/outbound-link-parser.test.ts | 32 ++++ .../src/test/tls-fingerprint-default.test.ts | 77 +++++++++ internal/amneziawgnet/netstack.go | 122 ++++++-------- internal/sub/hysteria_geco_uri_test.go | 152 ++++++++++++++++++ internal/sub/remote_routing.go | 7 +- internal/sub/remote_routing_test.go | 2 + internal/sub/salamander_uri_test.go | 41 ++++- internal/sub/service.go | 68 ++++++-- internal/util/link/outbound.go | 47 +++++- 14 files changed, 514 insertions(+), 111 deletions(-) create mode 100644 frontend/src/test/tls-fingerprint-default.test.ts create mode 100644 internal/sub/hysteria_geco_uri_test.go diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 80ebb59fb..4b4020179 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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 { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; +import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm'; import { getHeaderValue } from './headers'; import { canEnableTlsFlow } from './protocol-capabilities'; import { deriveSpiderX } from './spider-x'; @@ -437,7 +438,7 @@ export function genVlessLink(input: GenVlessLinkInput): string { params.set('security', 'tls'); if (stream.security === 'tls') { 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(',')); if (tls.serverName.length > 0) params.set('sni', tls.serverName); if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList); @@ -543,7 +544,7 @@ function writeTlsParams( ): void { if (stream.security !== 'tls') return; 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(',')); if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList); 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 obfsPassword = salamander?.settings?.password; if (typeof obfsPassword === 'string' && obfsPassword.length > 0) { - params.set('obfs', 'salamander'); + // packetSize (Gecko mode) exports via v2rayN's native fields; the + // experimental fm= 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); } } - applyFinalMaskToParams(stream.finalmask, params); - const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? ''; if (hopPorts.length > 0) { params.set('mport', hopPorts); diff --git a/frontend/src/lib/xray/inbound-tls-defaults.ts b/frontend/src/lib/xray/inbound-tls-defaults.ts index 78301d50c..87e1e3115 100644 --- a/frontend/src/lib/xray/inbound-tls-defaults.ts +++ b/frontend/src/lib/xray/inbound-tls-defaults.ts @@ -17,6 +17,12 @@ function defaultCertificate(): Record { export function createTlsSettingsWithDefaultCert(): Record { const tls = TlsStreamSettingsSchema.parse({}) as Record; tls.certificates = [defaultCertificate()]; + const settings = + tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings) + ? { ...(tls.settings as Record) } + : {}; + settings.fingerprint = 'chrome'; + tls.settings = settings; return tls; } diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 3d46f288e..c171a95fe 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -258,14 +258,34 @@ function ensureFinalMask(stream: Raw): 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= 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 { - 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'); 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 udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : []; const existing = udp.find( @@ -279,9 +299,16 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void { ) as Raw; if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password; + if ( + packetSize !== '' && + !(typeof settings.packetSize === 'string' && settings.packetSize.length > 0) + ) + settings.packetSize = packetSize; 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 diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 427ad8abe..21810c02c 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -130,8 +130,12 @@ export default function HostFormModal({ [], ); 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(() => { diff --git a/frontend/src/schemas/protocols/security/tls.ts b/frontend/src/schemas/protocols/security/tls.ts index 81ae8bae1..8277c9582 100644 --- a/frontend/src/schemas/protocols/security/tls.ts +++ b/frontend/src/schemas/protocols/security/tls.ts @@ -56,7 +56,9 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]); export type TlsCert = z.infer; 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(''), pinnedPeerCertSha256: z.array(z.string()).default([]), // Panel-only client directive (v2rayN `vcn`): verify the server certificate @@ -87,7 +89,7 @@ export const TlsStreamSettingsSchema = z.object({ masterKeyLog: z.string().optional(), echSockopt: SockoptStreamSettingsSchema.optional(), settings: TlsClientSettingsSchema.default({ - fingerprint: 'chrome', + fingerprint: '', echConfigList: '', pinnedPeerCertSha256: [], verifyPeerCertByName: '', diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 0c262cf7b..e0e7189e1 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -808,3 +808,35 @@ describe('parseOutboundLink dispatcher', () => { expect(parseOutboundLink(' ')).toBeNull(); }); }); + +describe('obfs=gecko packetSize validation', () => { + const base = 'hysteria2://secret@1.2.3.4: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).finalmask as + | Record + | undefined; + const udp = (finalmask?.udp ?? []) as Array>; + const mask = udp.find((m) => m.type === 'salamander'); + return (mask?.settings as Record | 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(); + }); +}); diff --git a/frontend/src/test/tls-fingerprint-default.test.ts b/frontend/src/test/tls-fingerprint-default.test.ts new file mode 100644 index 000000000..98f6c7639 --- /dev/null +++ b/frontend/src/test/tls-fingerprint-default.test.ts @@ -0,0 +1,77 @@ +/// +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)?.fingerprint).toBe('chrome'); + }); + + it('initializes hysteria TLS inbounds with empty fingerprint default', () => { + const tls = createHysteriaTlsSettingsWithDefaultCert(); + expect((tls.settings as Record)?.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='); + }); +}); diff --git a/internal/amneziawgnet/netstack.go b/internal/amneziawgnet/netstack.go index af1c17d00..a75df0146 100644 --- a/internal/amneziawgnet/netstack.go +++ b/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 import ( "fmt" "net/netip" "os" + "sync" "syscall" awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun" @@ -30,60 +23,39 @@ import ( "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 -// 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 { ep *channel.Endpoint stack *stack.Stack events chan awgtun.Event notifyHandle *channel.NotificationHandle incomingPacket chan *buffer.View + done chan struct{} + closeMu sync.Mutex + closed bool 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) { opts := stack.Options{ NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol}, 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, } 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), ""), stack: stack.New(opts), events: make(chan awgtun.Event, 10), incomingPacket: make(chan *buffer.View, tunQueueDepth), + done: make(chan struct{}), mtu: mtu, } 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) 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) { - view, ok := <-t.incomingPacket - if !ok { + var view *buffer.View + select { + case <-t.done: return 0, os.ErrClosed + case view = <-t.incomingPacket: } n, err := view.Read(buf[0][offset:]) if err != nil { @@ -160,10 +120,7 @@ func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) { count := 1 for count < len(buf) { select { - case view, ok := <-t.incomingPacket: - if !ok { - return count, nil - } + case view = <-t.incomingPacket: n, err := view.Read(buf[count][offset:]) if err != nil { return count, nil @@ -196,17 +153,41 @@ func (t *stackTun) Write(buf [][]byte, offset int) (int, error) { 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() { + t.closeMu.Lock() + if t.closed { + t.closeMu.Unlock() + return + } + t.closeMu.Unlock() + pkt := t.ep.Read() if pkt == nil { return } view := pkt.ToView() 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 { + 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.Close() t.ep.RemoveNotify(t.notifyHandle) @@ -214,25 +195,16 @@ func (t *stackTun) Close() error { if t.events != nil { close(t.events) } - if t.incomingPacket != nil { - close(t.incomingPacket) - } 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) { gstack.SetPromiscuousMode(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 { if a.Len() == 4 { var b [4]byte diff --git a/internal/sub/hysteria_geco_uri_test.go b/internal/sub/hysteria_geco_uri_test.go new file mode 100644 index 000000000..d69aecd60 --- /dev/null +++ b/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= 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://secret@203.0.113.1: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://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512#geo", + "half max only": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&maxPacketSize=1200#geo", + "non-numeric": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=abc&maxPacketSize=def#geo", + "zero min": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=0&maxPacketSize=1200#geo", + "inverted": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512#geo", + "over cap": "hysteria2://secret@203.0.113.1: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) + } +} diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go index dbfe9854e..731b526bd 100644 --- a/internal/sub/remote_routing.go +++ b/internal/sub/remote_routing.go @@ -66,6 +66,7 @@ type remoteRoutingFetch struct { } type remoteRoutingResolver struct { + refreshWG sync.WaitGroup mu sync.Mutex loadMu sync.Mutex loaded bool @@ -154,7 +155,11 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) r.inflight[key] = fetch 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 { return cached, true, nil } diff --git a/internal/sub/remote_routing_test.go b/internal/sub/remote_routing_test.go index c84308393..df7387923 100644 --- a/internal/sub/remote_routing_test.go +++ b/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) { t.Helper() + // Wait on refresh goroutines to prevent logging race after test teardown. + resolver.refreshWG.Wait() deadline := time.Now().Add(2 * time.Second) for { resolver.mu.Lock() diff --git a/internal/sub/salamander_uri_test.go b/internal/sub/salamander_uri_test.go index 2a9a1a06b..c7add43a6 100644 --- a/internal/sub/salamander_uri_test.go +++ b/internal/sub/salamander_uri_test.go @@ -11,13 +11,22 @@ import ( ) 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) } - 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) } + 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) { @@ -46,10 +55,34 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) } 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") if got := countWarnings(unsupportedID); got != 1 { 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") + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index cc90e3150..728815487 100644 --- a/internal/sub/service.go +++ b/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= finalmask dump breaks mihomo and other - // Hysteria2 clients that reject unknown query params. + // salamander obfs (Hysteria2): standard URI fields only -- an fm= + // dump breaks strict clients. packetSize exports as v2rayN's gecko pair. if finalmask, ok := stream["finalmask"].(map[string]any); ok { if udpMasks, ok := finalmask["udp"].([]any); ok { for _, m := range udpMasks { @@ -1188,13 +1187,23 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin } settings, _ := mask["settings"].(map[string]any) 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) 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) } } - params["obfs"] = "salamander" + if params["obfs"] == "" { + params["obfs"] = "salamander" + } params["obfs-password"] = pw break } @@ -1260,6 +1269,44 @@ func hysteriaHopPorts(stream map[string]any) string { 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 // the per-inbound resolveInboundAddress lookups are pure map reads. // 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 } -// 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 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) return extra diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 697d55921..25dde4e20 100644 --- a/internal/util/link/outbound.go +++ b/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 -// obfs=salamander & obfs-password= pair (every non-3x-ui client, and this -// panel's own generator, speak it instead of the private fm= 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) { - if !strings.EqualFold(p.Get("obfs"), "salamander") { + obfs := p.Get("obfs") + isGecko := strings.EqualFold(obfs, "gecko") + if !isGecko && !strings.EqualFold(obfs, "salamander") { return } password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword") if password == "" { 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") udp, _ := finalmask["udp"].([]any) for _, m := range udp { @@ -716,11 +742,20 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) { if pw, _ := settings["password"].(string); pw == "" { settings["password"] = password } + if packetSize != "" { + if ps, _ := settings["packetSize"].(string); ps == "" { + settings["packetSize"] = packetSize + } + } return } + settings := map[string]any{"password": password} + if packetSize != "" { + settings["packetSize"] = packetSize + } finalmask["udp"] = append(udp, map[string]any{ "type": "salamander", - "settings": map[string]any{"password": password}, + "settings": settings, }) }