mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 01:17:15 +00:00
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 <rqzbeh@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
|
||||||
// non-3x-ui client, and this panel's own generator, speak it instead of the
|
// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
|
||||||
// private fm=<json> dump). A salamander mask already carrying a password via fm=
|
|
||||||
// wins; a password-less one is completed rather than left empty.
|
|
||||||
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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -808,3 +808,35 @@ describe('parseOutboundLink dispatcher', () => {
|
|||||||
expect(parseOutboundLink(' ')).toBeNull();
|
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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
|
// Package amneziawgnet embeds amneziawg-go and gVisor netstack in-process
|
||||||
// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
|
// as a userspace alternative to kernel wireguard / awg-quick.
|
||||||
// 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
|
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
|
// tunQueueDepth is the outbound queue depth for channel endpoint and handoff.
|
||||||
// channel endpoint and the handoff channel to amneziawg-go's TUN reader
|
|
||||||
// (see the stackTun literal in createNetTUNWithStack for why both need it).
|
|
||||||
const tunQueueDepth = 1024
|
const tunQueueDepth = 1024
|
||||||
|
|
||||||
// stackTun implements amneziawg-go's tun.Device directly against a gVisor
|
// stackTun implements amneziawg-go tun.Device over a gVisor channel endpoint,
|
||||||
// channel endpoint, the same approach amneziawg-go's own tun/netstack
|
// exposing *stack.Stack for forwarder attachment.
|
||||||
// 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.
|
|
||||||
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
|
// createNetTUNWithStack builds a gVisor-backed tun.Device for localAddresses
|
||||||
// local addresses (interface address(es), one per family) and returns the
|
// and returns underlying *stack.Stack to attach forwarders.
|
||||||
// underlying *stack.Stack alongside it so a caller can attach a forwarder
|
|
||||||
// (see forwarder.go / udp.go).
|
|
||||||
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
|
// HandleLocal stays false so non-local destinations reach forwarder.
|
||||||
// forwarder.go) is what lets a destination other than the stack's
|
|
||||||
// own configured address reach the forwarder at all.
|
|
||||||
HandleLocal: false,
|
HandleLocal: false,
|
||||||
}
|
}
|
||||||
dev := &stackTun{
|
dev := &stackTun{
|
||||||
// tunQueueDepth matches channel.New's own outbound queue depth
|
// tunQueueDepth buffers channel.New and incomingPacket for pipelining.
|
||||||
// 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.
|
|
||||||
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
|
// Read drains incomingPacket into buf, supporting batched reads.
|
||||||
// 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.
|
|
||||||
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
|
var view *buffer.View
|
||||||
if !ok {
|
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:
|
case view = <-t.incomingPacket:
|
||||||
if !ok {
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
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,
|
// enablePromiscuousRouting configures NIC promiscuous and spoofing modes.
|
||||||
// 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.
|
|
||||||
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
|
// addrFromTcpip converts a gVisor tcpip.Address to netip.Addr.
|
||||||
// stdlib netip.Addr type the rest of this package and its callers use.
|
|
||||||
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
|
||||||
|
|||||||
@@ -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://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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"})
|
// packetSize exports as the v2rayN gecko fields when expressed; a truly
|
||||||
if want := []string{"packetSize"}; !reflect.DeepEqual(got, want) {
|
// 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
@@ -1176,9 +1176,8 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// salamander obfs (Hysteria2). Emit only the standard URI fields;
|
// salamander obfs (Hysteria2): standard URI fields only -- an fm=<json>
|
||||||
// the non-standard fm=<json> finalmask dump breaks mihomo and other
|
// dump breaks strict clients. packetSize exports as v2rayN's gecko pair.
|
||||||
// Hysteria2 clients that reject unknown query params.
|
|
||||||
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.
|
// extraSalamanderKeys lists salamander settings unexpressible in hysteria2 URI;
|
||||||
// A server using them rejects every client built from the emitted link.
|
// a server using any reported key rejects clients built from the link.
|
||||||
func extraSalamanderKeys(settings map[string]any) []string {
|
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" {
|
if k == "password" || (k == "packetSize" && expressedPacketSize) {
|
||||||
extra = append(extra, k)
|
continue
|
||||||
}
|
}
|
||||||
|
extra = append(extra, k)
|
||||||
}
|
}
|
||||||
sort.Strings(extra)
|
sort.Strings(extra)
|
||||||
return extra
|
return extra
|
||||||
|
|||||||
@@ -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
|
// obfs pair. An fm=-carried password wins; gecko adds the packetSize pair.
|
||||||
// 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.
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user