From cd10550f52683f63269bbc27ca7ff9ec9d2c5ed9 Mon Sep 17 00:00:00 2001 From: Medium1992 Date: Tue, 7 Jul 2026 16:20:42 +0300 Subject: [PATCH 1/2] fix: map hysteria2 gecko obfs for mihomo --- .../generators/mihomo.generator.service.ts | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/modules/subscription-template/generators/mihomo.generator.service.ts b/src/modules/subscription-template/generators/mihomo.generator.service.ts index 67232d43..c461f91d 100644 --- a/src/modules/subscription-template/generators/mihomo.generator.service.ts +++ b/src/modules/subscription-template/generators/mihomo.generator.service.ts @@ -43,7 +43,7 @@ interface Hysteria2FinalMask { }; udp?: Array<{ type?: string; - settings?: { password?: string }; + settings?: { packetSize?: string | number; password?: string }; }>; } @@ -647,12 +647,43 @@ export class MihomoGeneratorService { private buildHysteria2ObfsFields( finalMask: Record | null, ): Record { - const password = (finalMask as Hysteria2FinalMask | null)?.udp?.find( - (m) => m?.type === 'salamander', - )?.settings?.password; + const mask = (finalMask as Hysteria2FinalMask | null)?.udp?.find( + (m) => m?.type === 'salamander' || m?.type === 'gecko', + ); + const password = mask?.settings?.password; - if (!password) return {}; - return { obfs: 'salamander', 'obfs-password': password }; + if (!mask || !password) return {}; + + const packetSizeFields = this.buildHysteria2PacketSizeFields(mask.settings?.packetSize); + + if (mask.type !== 'gecko' && Object.keys(packetSizeFields).length === 0) { + return { obfs: 'salamander', 'obfs-password': password }; + } + + return { + obfs: 'gecko', + 'obfs-password': password, + ...packetSizeFields, + }; + } + + private buildHysteria2PacketSizeFields(packetSize?: string | number): Record { + if (!packetSize) return {}; + + const parts = String(packetSize).split('-', 2); + const [min, max] = parts; + const effectiveMax = parts.length === 1 ? min : max; + const minPacketSize = Number(min); + const maxPacketSize = Number(effectiveMax); + + return { + ...(min && Number.isFinite(minPacketSize) && minPacketSize > 0 && { + 'obfs-min-packet-size': minPacketSize, + }), + ...(effectiveMax && Number.isFinite(maxPacketSize) && maxPacketSize > 0 && { + 'obfs-max-packet-size': maxPacketSize, + }), + }; } private buildHysteria2TlsFields(host: ResolvedProxyConfig): Record { From abfc465e701047e821e7a29bc3e1ff4c4e344745 Mon Sep 17 00:00:00 2001 From: kastov Date: Tue, 7 Jul 2026 19:05:31 +0300 Subject: [PATCH 2/2] refactor: Hysteria2 obfuscation handling --- src/common/utils/index.ts | 1 + src/common/utils/parse-int-range.util.ts | 26 +++++ .../generators/mihomo.generator.service.ts | 99 ++++++++++++------- 3 files changed, 93 insertions(+), 33 deletions(-) create mode 100644 src/common/utils/parse-int-range.util.ts diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 050fc07a..740523eb 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -6,3 +6,4 @@ export * from './mask-string'; export * from './md5'; export * from './superjson'; export * from './truncate-header.util'; +export * from './parse-int-range.util'; diff --git a/src/common/utils/parse-int-range.util.ts b/src/common/utils/parse-int-range.util.ts new file mode 100644 index 00000000..80cf1f14 --- /dev/null +++ b/src/common/utils/parse-int-range.util.ts @@ -0,0 +1,26 @@ +export interface IntRange { + from: number | null; + to: number | null; +} + +export function parseIntRangeUtil(value: string | number | undefined): IntRange { + if (value === undefined || value === '') return { from: null, to: null }; + + const [left, right = left] = String(value).split('-', 2); + const from = parseIntPart(left); + const to = parseIntPart(right); + + if (from !== null && to !== null && from > to) { + return { from: to, to: from }; + } + + return { from, to }; +} + +function parseIntPart(part: string): number | null { + if (part === '') return null; + + const parsed = Number(part); + + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; +} diff --git a/src/modules/subscription-template/generators/mihomo.generator.service.ts b/src/modules/subscription-template/generators/mihomo.generator.service.ts index c461f91d..e359f36d 100644 --- a/src/modules/subscription-template/generators/mihomo.generator.service.ts +++ b/src/modules/subscription-template/generators/mihomo.generator.service.ts @@ -3,7 +3,7 @@ import yaml from 'yaml'; import { Injectable, Logger } from '@nestjs/common'; -import { isNonEmptyObject } from '@common/utils'; +import { isNonEmptyObject, parseIntRangeUtil } from '@common/utils'; import { FINGERPRINTS } from '@libs/contracts/constants'; import { SubscriptionTemplateService } from '@modules/subscription-template/subscription-template.service'; @@ -41,12 +41,25 @@ interface Hysteria2FinalMask { bbrProfile?: string; congestion?: string; }; - udp?: Array<{ - type?: string; - settings?: { packetSize?: string | number; password?: string }; - }>; } +interface Hysteria2Mask { + type: 'salamander'; + settings: { + packetSize?: string | number; + password: string; + }; +} + +interface Hysteria2PacketSizeFields { + 'obfs-max-packet-size': number; + 'obfs-min-packet-size': number; +} + +type Hysteria2ObfsFields = + | ({ obfs: 'gecko'; 'obfs-password': string } & Partial) + | { obfs: 'salamander'; 'obfs-password': string }; + interface ProxyNode { [key: string]: unknown; alpn?: string[]; @@ -646,43 +659,24 @@ export class MihomoGeneratorService { private buildHysteria2ObfsFields( finalMask: Record | null, - ): Record { - const mask = (finalMask as Hysteria2FinalMask | null)?.udp?.find( - (m) => m?.type === 'salamander' || m?.type === 'gecko', - ); - const password = mask?.settings?.password; + ): Hysteria2ObfsFields | Record { + const mask = this.findHysteria2Mask(finalMask); - if (!mask || !password) return {}; + if (!mask) return {}; - const packetSizeFields = this.buildHysteria2PacketSizeFields(mask.settings?.packetSize); + const { password, packetSize } = mask.settings; - if (mask.type !== 'gecko' && Object.keys(packetSizeFields).length === 0) { + if (!packetSize) { return { obfs: 'salamander', 'obfs-password': password }; } + const { from, to } = parseIntRangeUtil(packetSize); + return { obfs: 'gecko', 'obfs-password': password, - ...packetSizeFields, - }; - } - - private buildHysteria2PacketSizeFields(packetSize?: string | number): Record { - if (!packetSize) return {}; - - const parts = String(packetSize).split('-', 2); - const [min, max] = parts; - const effectiveMax = parts.length === 1 ? min : max; - const minPacketSize = Number(min); - const maxPacketSize = Number(effectiveMax); - - return { - ...(min && Number.isFinite(minPacketSize) && minPacketSize > 0 && { - 'obfs-min-packet-size': minPacketSize, - }), - ...(effectiveMax && Number.isFinite(maxPacketSize) && maxPacketSize > 0 && { - 'obfs-max-packet-size': maxPacketSize, - }), + ...(from && { 'obfs-min-packet-size': from }), + ...(to && { 'obfs-max-packet-size': to }), }; } @@ -709,4 +703,43 @@ export class MihomoGeneratorService { } } } + + private findHysteria2Mask(finalMask: Record | null): Hysteria2Mask | null { + const udp = finalMask?.udp; + + if (!Array.isArray(udp)) return null; + + return udp.find((mask): mask is Hysteria2Mask => this.isHysteria2Mask(mask)) ?? null; + } + + private isHysteria2Mask(value: unknown): value is Hysteria2Mask { + if ( + typeof value !== 'object' || + value === null || + !('type' in value) || + !('settings' in value) + ) { + return false; + } + + if (value.type !== 'salamander') { + return false; + } + + const settings: unknown = value.settings; + if (typeof settings !== 'object' || settings === null || !('password' in settings)) { + return false; + } + + if (typeof settings.password !== 'string' || settings.password.length === 0) { + return false; + } + + const packetSize: unknown = 'packetSize' in settings ? settings.packetSize : undefined; + return ( + packetSize === undefined || + typeof packetSize === 'string' || + typeof packetSize === 'number' + ); + } }