diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 775273fc1..f9e599ce1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,9 @@ on: - 'nb4a.properties' env: MIERU_VERSION: v3.34.0 + HYSTERIA_VERSION: v2.9.2 +permissions: + contents: read jobs: libcore: name: Native Build (LibCore) @@ -34,21 +37,21 @@ jobs: - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - mieru: - name: Native Build (Mieru) + sidecars: + name: Native Build (Sidecars) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache id: cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -57,12 +60,15 @@ jobs: - name: Mieru Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib mieru + - name: Hysteria2 Download + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib hysteria2 build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore - - mieru + - sidecars steps: - name: Checkout uses: actions/checkout@v5 @@ -76,22 +82,24 @@ jobs: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache - id: mieru-cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache + id: sidecars-cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru - - name: Verify Mieru Artifacts + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars + - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - if [ ! -f "app/executableSo/$abi/libmieru.so" ]; then - echo "Error: missing app/executableSo/$abi/libmieru.so (Mieru cache miss)" >&2 - exit 1 - fi + for so in libmieru.so libhysteria2.so; do + if [ ! -f "app/executableSo/$abi/$so" ]; then + echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 + exit 1 + fi + done done - name: Gradle cache uses: actions/cache@v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2944c540f..070847f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: pull_request: env: MIERU_VERSION: v3.34.0 + HYSTERIA_VERSION: v2.9.2 +permissions: + contents: read jobs: libcore: name: Native Build (LibCore) @@ -39,21 +42,21 @@ jobs: - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - mieru: - name: Native Build (Mieru) + sidecars: + name: Native Build (Sidecars) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache id: cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru-ci + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars-ci - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -62,12 +65,15 @@ jobs: - name: Mieru Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib mieru + - name: Hysteria2 Download + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib hysteria2 build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore - - mieru + - sidecars steps: - name: Checkout uses: actions/checkout@v5 @@ -86,22 +92,24 @@ jobs: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}-ci - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache - id: mieru-cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache + id: sidecars-cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru-ci - - name: Verify Mieru Artifacts + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars-ci + - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - if [ ! -f "app/executableSo/$abi/libmieru.so" ]; then - echo "Error: missing app/executableSo/$abi/libmieru.so (Mieru cache miss)" >&2 - exit 1 - fi + for so in libmieru.so libhysteria2.so; do + if [ ! -f "app/executableSo/$abi/$so" ]; then + echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 + exit 1 + fi + done done - name: Gradle cache uses: actions/cache@v5 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index bb0589fc8..04a12d2c5 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -4,6 +4,9 @@ on: inputs: env: MIERU_VERSION: v3.34.0 + HYSTERIA_VERSION: v2.9.2 +permissions: + contents: read jobs: libcore: name: Native Build (LibCore) @@ -30,21 +33,21 @@ jobs: - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - mieru: - name: Native Build (Mieru) + sidecars: + name: Native Build (Sidecars) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache id: cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -53,12 +56,15 @@ jobs: - name: Mieru Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib mieru + - name: Hysteria2 Download + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib hysteria2 build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore - - mieru + - sidecars steps: - name: Checkout uses: actions/checkout@v5 @@ -72,22 +78,24 @@ jobs: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache - id: mieru-cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache + id: sidecars-cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru - - name: Verify Mieru Artifacts + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars + - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - if [ ! -f "app/executableSo/$abi/libmieru.so" ]; then - echo "Error: missing app/executableSo/$abi/libmieru.so (Mieru cache miss)" >&2 - exit 1 - fi + for so in libmieru.so libhysteria2.so; do + if [ ! -f "app/executableSo/$abi/$so" ]; then + echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 + exit 1 + fi + done done - name: Gradle cache uses: actions/cache@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3e73c263..91d352a6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ on: required: false env: MIERU_VERSION: v3.34.0 + HYSTERIA_VERSION: v2.9.2 +permissions: + contents: write jobs: libcore: name: Native Build (LibCore) @@ -36,21 +39,21 @@ jobs: - name: Native Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib core - mieru: - name: Native Build (Mieru) + sidecars: + name: Native Build (Sidecars) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache id: cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -59,12 +62,15 @@ jobs: - name: Mieru Build if: steps.cache.outputs.cache-hit != 'true' run: ./run lib mieru + - name: Hysteria2 Download + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib hysteria2 build: name: Build OSS APK runs-on: ubuntu-latest needs: - libcore - - mieru + - sidecars steps: - name: Checkout uses: actions/checkout@v5 @@ -78,22 +84,24 @@ jobs: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - - name: Mieru Status - run: cat buildScript/lib/mieru.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > mieru_status - - name: Mieru Cache - id: mieru-cache + - name: Sidecars Status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + - name: Sidecars Cache + id: sidecars-cache uses: actions/cache@v5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'mieru_status') }}-${{ env.MIERU_VERSION }}-mieru - - name: Verify Mieru Artifacts + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-sidecars + - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - if [ ! -f "app/executableSo/$abi/libmieru.so" ]; then - echo "Error: missing app/executableSo/$abi/libmieru.so (Mieru cache miss)" >&2 - exit 1 - fi + for so in libmieru.so libhysteria2.so; do + if [ ! -f "app/executableSo/$abi/$so" ]; then + echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 + exit 1 + fi + done done - name: Gradle cache uses: actions/cache@v5 diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index ea521798d..5da8b12dc 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -129,6 +129,10 @@ object Key { const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery" const val SERVER_HOP_INTERVAL = "hopInterval" + const val SERVER_HY2_OBFS_TYPE = "serverHy2ObfsType" + const val SERVER_HY2_GECKO_MIN_PACKET = "serverHy2GeckoMinPacket" + const val SERVER_HY2_GECKO_MAX_PACKET = "serverHy2GeckoMaxPacket" + const val SERVER_PRIVATE_KEY = "serverPrivateKey" const val SERVER_INSECURE_CONCURRENCY = "serverInsecureConcurrency" diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt index 511154ff0..2d97a606b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt @@ -10,7 +10,7 @@ import androidx.core.text.isDigitsOnly object Executable { private val EXECUTABLES = setOf( - "libtrojan.so", "libtrojan-go.so", "libnaive.so", "libtuic.so", "libhysteria.so", "libmieru.so" + "libtrojan.so", "libtrojan-go.so", "libnaive.so", "libtuic.so", "libhysteria.so", "libmieru.so", "libhysteria2.so" ) fun killAll(alsoKillBg: Boolean = false) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index d9aa300e2..59b54d311 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -10,6 +10,7 @@ import io.nekohasekai.sagernet.fmt.ConfigBuildResult import io.nekohasekai.sagernet.fmt.buildConfig import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria1Config +import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria2SidecarConfig import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean @@ -74,13 +75,26 @@ abstract class BoxInstance( } is HysteriaBean -> { - initPlugin("hysteria-plugin") - pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) { - File( - app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca" - ).apply { - parentFile?.mkdirs() - cacheFiles.add(this) + if (bean.protocolVersion == 2 && + bean.hysteria2ObfsType == HysteriaBean.OBFS_GECKO + ) { + // Gecko obfs: use the bundled official hysteria binary sidecar. + // Loopback-only SOCKS5 (no auth), matching the other sidecars + // (Mieru/Naive); the sing-box socks outbound dials 127.0.0.1:port. + initPlugin("hysteria2-plugin") + pluginConfigs[port] = profile.type to bean.buildHysteria2SidecarConfig( + port, + File(app.noBackupFilesDir, "protect_path").absolutePath + ) + } else { + initPlugin("hysteria-plugin") + pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) { + File( + app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca" + ).apply { + parentFile?.mkdirs() + cacheFiles.add(this) + } } } } @@ -171,6 +185,30 @@ abstract class BoxInstance( processes.start(commands, envMap) } + bean is HysteriaBean && bean.protocolVersion == 2 && + bean.hysteria2ObfsType == HysteriaBean.OBFS_GECKO -> { + // Official apernet/hysteria client binary (Gecko obfs sidecar). + // viper detects the config format from the .json extension. + val configFile = File( + cacheDir, "hysteria2_" + SystemClock.elapsedRealtime() + ".json" + ) + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val commands = mutableListOf( + initPlugin("hysteria2-plugin").path, + "--disable-update-check", + "--config", + configFile.absolutePath, + "--log-level", + if (DataStore.logLevel > 0) "debug" else "warn", + "client" + ) + + processes.start(commands) + } + bean is HysteriaBean -> { val configFile = File( cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".json" diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index 5ca0e3e17..07b77ede5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -252,6 +252,10 @@ object DataStore : OnPreferenceDataStoreChangeListener { var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY) var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 } + var serverHy2ObfsType by profileCacheStore.stringToInt(Key.SERVER_HY2_OBFS_TYPE) + var serverHy2GeckoMinPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MIN_PACKET) { 512 } + var serverHy2GeckoMaxPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MAX_PACKET) { 1200 } + var protocolVersion by profileCacheStore.stringToInt(Key.PROTOCOL_VERSION) { 2 } // default is SOCKS5 var serverProtocolInt by profileCacheStore.stringToInt(Key.SERVER_PROTOCOL) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java index 2d2e19e1d..9bd532eca 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java @@ -46,6 +46,15 @@ public class HysteriaBean extends AbstractBean { public static final int PROTOCOL_WECHAT_VIDEO = 2; public Integer protocol; + // Hysteria2 obfuscation type (HY2 only). The shared `obfuscation` field holds the password. + public static final int OBFS_NONE = 0; + public static final int OBFS_SALAMANDER = 1; + public static final int OBFS_GECKO = 2; + public Integer hysteria2ObfsType; + // Gecko obfs packet-size bounds (HY2 + Gecko only). + public Integer geckoMinPacketSize; + public Integer geckoMaxPacketSize; + @Override public boolean canMapping() { return protocol != PROTOCOL_FAKETCP; @@ -60,6 +69,12 @@ public void initializeDefaultValues() { if (authPayload == null) authPayload = ""; if (protocol == null) protocol = PROTOCOL_UDP; if (obfuscation == null) obfuscation = ""; + if (hysteria2ObfsType == null) { + // Backward compat: pre-Gecko HY2 profiles with a password used Salamander. + hysteria2ObfsType = obfuscation.isEmpty() ? OBFS_NONE : OBFS_SALAMANDER; + } + if (geckoMinPacketSize == null) geckoMinPacketSize = 512; + if (geckoMaxPacketSize == null) geckoMaxPacketSize = 1200; if (sni == null) sni = ""; if (alpn == null) alpn = ""; if (caText == null) caText = ""; @@ -82,7 +97,7 @@ public void initializeDefaultValues() { @Override public void serialize(ByteBufferOutput output) { - output.writeInt(7); + output.writeInt(8); super.serialize(output); output.writeInt(protocolVersion); @@ -104,6 +119,10 @@ public void serialize(ByteBufferOutput output) { output.writeBoolean(disableMtuDiscovery); output.writeInt(hopInterval); output.writeString(serverPorts); + + output.writeInt(hysteria2ObfsType); + output.writeInt(geckoMinPacketSize); + output.writeInt(geckoMaxPacketSize); } @Override @@ -148,6 +167,13 @@ public void deserialize(ByteBufferInput input) { serverPorts = serverPort.toString(); } } + if (version >= 8) { + hysteria2ObfsType = input.readInt(); + geckoMinPacketSize = input.readInt(); + geckoMaxPacketSize = input.readInt(); + } + // For version < 8, hysteria2ObfsType/gecko* stay null and are derived in + // initializeDefaultValues() (Salamander when an obfuscation password exists). } @Override diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt index 29b584223..5af498134 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt @@ -95,6 +95,20 @@ fun parseHysteria2(url: String): HysteriaBean { link.queryParameter("obfs-password")?.also { obfuscation = it } + // obfs type: salamander (default when password present) or gecko. + link.queryParameter("obfs")?.also { + hysteria2ObfsType = when (it.lowercase()) { + "gecko" -> HysteriaBean.OBFS_GECKO + "salamander" -> HysteriaBean.OBFS_SALAMANDER + else -> HysteriaBean.OBFS_NONE + } + } + link.queryParameter("obfs-min-packet-size")?.toIntOrNull()?.also { + geckoMinPacketSize = it + } + link.queryParameter("obfs-max-packet-size")?.toIntOrNull()?.also { + geckoMaxPacketSize = it + } // link.queryParameter("pinSHA256")?.also { // // TODO your box do not support it // } @@ -156,9 +170,20 @@ fun HysteriaBean.toUri(): String { if (sni.isNotBlank()) { builder.addQueryParameter("sni", sni) } - if (obfuscation.isNotBlank()) { - builder.addQueryParameter("obfs", "salamander") - builder.addQueryParameter("obfs-password", obfuscation) + if (obfuscation.isNotBlank() && hysteria2ObfsType != HysteriaBean.OBFS_NONE) { + when (hysteria2ObfsType) { + HysteriaBean.OBFS_GECKO -> { + builder.addQueryParameter("obfs", "gecko") + builder.addQueryParameter("obfs-password", obfuscation) + builder.addQueryParameter("obfs-min-packet-size", geckoMinPacketSize.toString()) + builder.addQueryParameter("obfs-max-packet-size", geckoMaxPacketSize.toString()) + } + + else -> { + builder.addQueryParameter("obfs", "salamander") + builder.addQueryParameter("obfs-password", obfuscation) + } + } } } return builder.toLink(if (protocolVersion == 2) "hy2" else "hysteria") @@ -269,6 +294,9 @@ fun getFirstPort(portStr: String): Int { fun HysteriaBean.canUseSingBox(): Boolean { if (protocol != HysteriaBean.PROTOCOL_UDP) return false + // The starifly sing-box core only supports Salamander obfs. Gecko obfs is provided by + // the bundled official hysteria binary sidecar, so force the external path for Gecko. + if (protocolVersion == 2 && hysteria2ObfsType == HysteriaBean.OBFS_GECKO) return false return true } @@ -325,7 +353,9 @@ fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBox hop_interval = "${bean.hopInterval}s" up_mbps = bean.uploadMbps down_mbps = bean.downloadMbps - if (bean.obfuscation.isNotBlank()) { + if (bean.obfuscation.isNotBlank() && bean.hysteria2ObfsType != HysteriaBean.OBFS_NONE) { + // Native sing-box core only supports Salamander; Gecko goes through the + // bundled hysteria binary sidecar (canUseSingBox() == false for Gecko). obfs = SingBoxOptions.Hysteria2Obfs().apply { type = "salamander" password = bean.obfuscation @@ -366,3 +396,74 @@ fun hopPortsToSingboxList(s: String): List { } } } + +/** + * Builds a config for the bundled official apernet/hysteria client binary (sidecar), + * used for Hysteria2 profiles with Gecko obfs (which the native sing-box core can't do). + * + * Emitted as JSON (hysteria uses viper, which detects format by the .json extension). + * The client's upstream QUIC sockets are protected from the VPN via + * quic.sockopts.fdControlUnixSocket, pointing at libcore's protect_server socket + * ("protect_path" in the process working dir). It exposes a loopback-only SOCKS5 listener + * that the generated sing-box "socks" outbound dials (no auth, matching the other sidecars). + * + * @param port local SOCKS5 port for the sidecar to listen on (== sing-box outbound port). + * @param protectPath absolute path to libcore's protect unix socket. + */ +fun HysteriaBean.buildHysteria2SidecarConfig( + port: Int, + protectPath: String, +): String { + if (protocolVersion != 2) { + throw Exception("error version: $protocolVersion") + } + var realSni = sni + if (realSni.isBlank() && !serverAddress.isIpAddress()) { + realSni = serverAddress + } + return JSONObject().apply { + put("server", "$serverAddress:$serverPorts") + if (authPayload.isNotBlank()) put("auth", authPayload) + put("tls", JSONObject().apply { + if (realSni.isNotBlank()) put("sni", realSni) + put("insecure", allowInsecure || DataStore.globalAllowInsecure) + if (caText.isNotBlank()) put("ca", caText) + }) + if (hysteria2ObfsType == HysteriaBean.OBFS_GECKO && obfuscation.isNotBlank()) { + // Clamp to hysteria's accepted bounds: 1 <= min <= max <= 2048. + val min = geckoMinPacketSize.coerceIn(1, 2048) + val max = geckoMaxPacketSize.coerceIn(min, 2048) + put("obfs", JSONObject().apply { + put("type", "gecko") + put("gecko", JSONObject().apply { + put("password", obfuscation) + put("minPacketSize", min) + put("maxPacketSize", max) + }) + }) + } else if (hysteria2ObfsType == HysteriaBean.OBFS_SALAMANDER && obfuscation.isNotBlank()) { + put("obfs", JSONObject().apply { + put("type", "salamander") + put("salamander", JSONObject().apply { + put("password", obfuscation) + }) + }) + } + if (uploadMbps > 0 || downloadMbps > 0) { + put("bandwidth", JSONObject().apply { + if (uploadMbps > 0) put("up", "$uploadMbps mbps") + if (downloadMbps > 0) put("down", "$downloadMbps mbps") + }) + } + put("quic", JSONObject().apply { + put("sockopts", JSONObject().apply { + put("fdControlUnixSocket", protectPath) + }) + }) + put("socks5", JSONObject().apply { + put("listen", "$LOCALHOST:$port") + put("disableUDP", false) + }) + put("lazy", true) + }.toStringPretty() +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt index 82bef5933..b957eb2d7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt @@ -22,6 +22,9 @@ class HysteriaSettingsActivity : ProfileSettingsActivity() { DataStore.serverAddress = serverAddress DataStore.serverPorts = serverPorts DataStore.serverObfs = obfuscation + DataStore.serverHy2ObfsType = hysteria2ObfsType + DataStore.serverHy2GeckoMinPacket = geckoMinPacketSize + DataStore.serverHy2GeckoMaxPacket = geckoMaxPacketSize DataStore.serverAuthType = authPayloadType DataStore.serverProtocolInt = protocol DataStore.serverPassword = authPayload @@ -43,6 +46,9 @@ class HysteriaSettingsActivity : ProfileSettingsActivity() { serverAddress = DataStore.serverAddress serverPorts = DataStore.serverPorts obfuscation = DataStore.serverObfs + hysteria2ObfsType = DataStore.serverHy2ObfsType + geckoMinPacketSize = DataStore.serverHy2GeckoMinPacket + geckoMaxPacketSize = DataStore.serverHy2GeckoMaxPacket authPayloadType = DataStore.serverAuthType authPayload = DataStore.serverPassword protocol = DataStore.serverProtocolInt @@ -75,6 +81,41 @@ class HysteriaSettingsActivity : ProfileSettingsActivity() { val protocol = findPreference(Key.SERVER_PROTOCOL)!! val alpn = findPreference(Key.SERVER_ALPN)!! + // Hysteria2 obfs type selector: controls visibility of the obfs password and the + // Gecko packet-size fields. Only shown for HY2. + val obfsType = findPreference(Key.SERVER_HY2_OBFS_TYPE)!! + val obfsPassword = findPreference(Key.SERVER_OBFS)!! + val geckoMin = findPreference(Key.SERVER_HY2_GECKO_MIN_PACKET)!! + val geckoMax = findPreference(Key.SERVER_HY2_GECKO_MAX_PACKET)!! + geckoMin.setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + geckoMax.setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + + fun updateObfs(type: Int, version: Int) { + // For HY1 the legacy single obfs password field is used; for HY2 use the + // type selector and show password/gecko fields accordingly. + val isHy2 = version == 2 + obfsType.isVisible = isHy2 + if (isHy2) { + // Both Salamander and Gecko require an obfs password (apernet/hysteria + // clientConfigObfsGecko.Password is mandatory), so show it for either. + obfsPassword.isVisible = type != HysteriaBean.OBFS_NONE + val isGecko = type == HysteriaBean.OBFS_GECKO + geckoMin.isVisible = isGecko + geckoMax.isVisible = isGecko + } else { + obfsPassword.isVisible = true + geckoMin.isVisible = false + geckoMax.isVisible = false + } + } + obfsType.setOnPreferenceChangeListener { _, newValue -> + updateObfs( + newValue.toString().toIntOrNull() ?: HysteriaBean.OBFS_NONE, + DataStore.protocolVersion + ) + true + } + fun updateVersion(v: Int) { if (v == 2) { authPayload.isVisible = true @@ -106,6 +147,7 @@ class HysteriaSettingsActivity : ProfileSettingsActivity() { // authPayload.title = resources.getString(R.string.hysteria_auth_payload) } + updateObfs(DataStore.serverHy2ObfsType, v) } findPreference(Key.PROTOCOL_VERSION)!!.setOnPreferenceChangeListener { _, newValue -> updateVersion(newValue.toString().toIntOrNull() ?: 1) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 60d581c12..c6183e3e6 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -518,6 +518,17 @@ 2 + + @string/plugin_disabled + Salamander + Gecko + + + 0 + 1 + 2 + + 5 4 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 689e0340d..707bd37ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -421,6 +421,9 @@ Select Applications %d Applications Obfuscation Password + Obfuscation Type + Gecko Min Packet Size + Gecko Max Packet Size Authentication Type Authentication Payload Max Upload Speed (in Mbps) diff --git a/app/src/main/res/xml/hysteria_preferences.xml b/app/src/main/res/xml/hysteria_preferences.xml index 1ff4042c3..c431c1e23 100644 --- a/app/src/main/res/xml/hysteria_preferences.xml +++ b/app/src/main/res/xml/hysteria_preferences.xml @@ -27,12 +27,30 @@ app:key="serverPorts" app:title="@string/server_port" app:useSimpleSummaryProvider="true" /> + + + /libhysteria2.so). +# +# Used for Hysteria2 profiles with Gecko obfs, which the native (starifly) sing-box core +# does not support. PluginManager.initNativeInternal resolves +# "hysteria2-plugin" -> libhysteria2.so from nativeLibraryDir. +# +# Usage: ./run lib hysteria2 +set -e +set -o pipefail + +# Pinned Hysteria release (app/v2.9.2 introduced Gecko obfs). +HYSTERIA_VERSION="${HYSTERIA_VERSION:-v2.9.2}" +BASE="https://github.com/apernet/hysteria/releases/download/app/${HYSTERIA_VERSION}" + +OUT="$(pwd)/app/executableSo" + +# Fetch the official checksums once for integrity verification. +HASHES="$(curl -fsSL --retry 3 --retry-delay 2 --max-time 120 "$BASE/hashes.txt")" + +sha256_tool() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +dl() { + local abi="$1" asset="$2" + echo ">> downloading libhysteria2.so for $abi ($asset)" + mkdir -p "$OUT/$abi" + curl -fL --retry 3 --retry-delay 2 --max-time 300 "$BASE/$asset" -o "$OUT/$abi/libhysteria2.so" + # Verify against the official SHA256 (hashes.txt lines: " build/"). + local expected actual + expected="$(printf '%s\n' "$HASHES" | awk -v a="build/$asset" '$2==a {print $1}')" + if [ -z "$expected" ]; then + echo "Error: no checksum for $asset in hashes.txt" >&2 + exit 1 + fi + actual="$(sha256_tool "$OUT/$abi/libhysteria2.so")" + if [ "$expected" != "$actual" ]; then + echo "Error: checksum mismatch for $asset (expected $expected, got $actual)" >&2 + exit 1 + fi + chmod +x "$OUT/$abi/libhysteria2.so" +} + +dl "arm64-v8a" "hysteria-android-arm64" +dl "armeabi-v7a" "hysteria-android-armv7" +dl "x86" "hysteria-android-386" +dl "x86_64" "hysteria-android-amd64" + +echo ">> installed Hysteria2 binaries:" +ls -la "$OUT"/*/libhysteria2.so diff --git a/docs/maintenance/hysteria2-gecko-sidecar.md b/docs/maintenance/hysteria2-gecko-sidecar.md new file mode 100644 index 000000000..f7c95ab90 --- /dev/null +++ b/docs/maintenance/hysteria2-gecko-sidecar.md @@ -0,0 +1,101 @@ +# Hysteria2 Gecko obfs via bundled official-binary sidecar + +Date: 2026-06-15 +Status: Implementing. Supersedes `hysteria2-gecko-deferral.md` (Gecko is no longer blocked +on a sing-box re-platform). + +## Decision +Ship Hysteria2 "Gecko" obfuscation by bundling the **official `apernet/hysteria`** Android +client binary as a sidecar (the same pattern as Mieru), instead of re-platforming the +sing-box core. This keeps the starifly core (and SSR/Snell/Juicity) intact and is a +days-scale change rather than a weeks-scale core migration. (Endorsed by external review.) + +## Why this works cleanly +- Official hysteria ships Android binaries for all 4 ABIs (`hysteria-android-{arm64,armv7,amd64,386}`) + in release `app/v2.9.2` — no cross-compile needed. +- Official hysteria client config has `obfs.type: gecko` with `obfs.gecko.{password,minPacketSize,maxPacketSize}`. +- Official hysteria has a built-in Android FD-protect hook: + `quic.sockopts.fdControlUnixSocket` — it connects to a unix socket and sends each QUIC + socket fd for protection. NekoBox **already** runs exactly such a server: libcore's + `protect_server.ServeProtect("protect_path", ...)` (box.go:245) listens on the relative + socket `protect_path` in the process working dir (`noBackupFilesDir`) and calls + `VpnService.protect(fd)`. Sidecars run with that cwd. So we point hysteria's + `fdControlUnixSocket` at that socket — no upstream patch, no new bridge. + +## Routing model (which HY2 profiles use the sidecar) +- Non-obfs and **Salamander** Hysteria2 keep using the native starifly sing-box `hysteria2` + outbound (unchanged — `canUseSingBox()` stays true). +- **Gecko** Hysteria2 routes to the bundled official-binary sidecar: `canUseSingBox()` + returns false for Gecko, so the existing `needExternal()` machinery builds a sing-box + `socks` outbound + dokodemo mapping pointing at the sidecar's local SOCKS5, exactly like + Mieru/Naive/HY1. + +## Data model (HysteriaBean) +Keep `obfuscation` (the obfs password — backward compatible; existing Salamander profiles +keep working). Add: +- `hysteria2ObfsType: Int` — 0=NONE, 1=SALAMANDER, 2=GECKO (default derived: if + `obfuscation` non-blank and type unset on old data => SALAMANDER, else NONE). +- `geckoMinPacketSize: Int` (default 512), `geckoMaxPacketSize: Int` (default 1200). +- Bump kryo serialize version 7 -> 8; deserialize reads the new fields only when + `version >= 8`, defaulting otherwise (old profiles => SALAMANDER if they had obfuscation). +Validation: gecko requires password; `1 <= min <= max <= 2048`. + +## Config generation (new `buildHysteria2SidecarConfig`) +Emit hysteria client YAML (the official schema): +```yaml +server: ":" +auth: "" +tls: { sni: "", insecure: } # ca via caText if set +obfs: + type: gecko + gecko: { password: "", minPacketSize: , maxPacketSize: } +quic: + sockopts: + fdControlUnixSocket: "/protect_path" +socks5: + listen: "127.0.0.1:" + username: "" + password: "" + disableUDP: false +lazy: true +``` +- Pre-resolve the server domain to an IP in NekoBox (set `server` to IP, `tls.sni` to the + original host) so the sidecar's own DNS doesn't loop into the VPN — the one extra + correctness item beyond fd-protect. (Hysteria mapping already routes the server via the + dokodemo `direct` inbound through the core, which handles this; verify.) +- The sing-box `socks` outbound must carry the same random SOCKS user/pass. + +## Binary packaging +- `buildScript/lib/hysteria2.sh` (`./run lib hysteria2`): download official + `hysteria-android-{arm64,armv7,amd64,386}` from `app/v2.9.2`, install as + `app/executableSo//libhysteria2.so`. (Mirror `buildScript/lib/mieru.sh`; here we can + download prebuilt instead of cross-compiling.) +- `PluginManager.initNativeInternal` already maps `hysteria2-plugin -> libhysteria2.so`. +- CI: the existing Mieru job was generalized into a combined `Native Build (Sidecars)` job + that builds/caches both `libmieru.so` and `libhysteria2.so` (one `app/executableSo` cache), + with a verify step covering all 4 ABIs and both binaries, across all 4 workflows. +- `Executable.EXECUTABLES` already lists `libhysteria.so`; add `libhysteria2.so`. + +## BoxInstance wiring +- `init()`: for HysteriaBean with protocolVersion==2 && Gecko, `initPlugin("hysteria2-plugin")` + and store `buildHysteria2SidecarConfig(port, protectPath)` (JSON config; viper detects the + format from the `.json` extension). +- `launch()`: new branch — write the config to cache and invoke the official binary as + `libhysteria2.so --disable-update-check --config --log-level client`. + +## UI (hysteria_preferences.xml + HysteriaSettingsActivity) +- Replace the single obfs password field with an obfs **type selector** (None/Salamander/Gecko) + + password + gecko min/max packet-size (shown only for Gecko, HY2 only). +- DataStore wiring for the new fields. + +## Tests / verification +- Build core + APK; confirm `libhysteria2.so` packaged for all 4 ABIs. +- Existing Salamander HY2 profile still uses native outbound, round-trips unchanged. +- Gecko profile: import/export URI (`obfs=gecko&obfs-password=&obfs-min/max-packet-size`), + generated YAML correctness, sidecar launches, sing-box socks outbound points at it. +- Runtime connect test deferred to the emulator QA pass (x86 metal, quota now approved). + +## Scope guard +Touches: HysteriaBean (+serialization), HysteriaFmt (canUseSingBox/parse/export/new YAML +builder), ConfigBuilder (Gecko obfs already handled by needExternal path — verify pluginId), +BoxInstance (init+launch branches), UI, build script + CI, Executable. No core/Go change.