diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eaa843cb..062c79d16 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,8 @@ on: env: MIERU_VERSION: v3.34.0 HYSTERIA_VERSION: v2.9.2 + MDVPN_REF: android-vpnservice-protect-hook + MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 permissions: contents: read @@ -25,7 +27,7 @@ jobs: run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar @@ -45,14 +47,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -64,6 +66,9 @@ jobs: - name: Hysteria2 Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib hysteria2 + - name: MasterDnsVPN Build + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib masterdnsvpn - name: Naive Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive @@ -81,24 +86,24 @@ jobs: - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: sidecars-cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - for so in libmieru.so libhysteria2.so libnaive.so; do + for so in libmieru.so libhysteria2.so libmasterdnsvpn.so libnaive.so; do if [ ! -f "app/executableSo/$abi/$so" ]; then echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 exit 1 @@ -106,7 +111,7 @@ jobs: done done - name: Gradle cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b186daaab..e0abb0eb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: env: MIERU_VERSION: v3.34.0 HYSTERIA_VERSION: v2.9.2 + MDVPN_REF: android-vpnservice-protect-hook + MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 permissions: contents: read @@ -30,7 +32,7 @@ jobs: run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar @@ -50,14 +52,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars-ci + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars-ci - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -69,6 +71,9 @@ jobs: - name: Hysteria2 Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib hysteria2 + - name: MasterDnsVPN Build + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib masterdnsvpn - name: Naive Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive @@ -91,24 +96,24 @@ jobs: - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}-ci - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: sidecars-cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars-ci + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars-ci - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - for so in libmieru.so libhysteria2.so libnaive.so; do + for so in libmieru.so libhysteria2.so libmasterdnsvpn.so libnaive.so; do if [ ! -f "app/executableSo/$abi/$so" ]; then echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 exit 1 @@ -116,7 +121,7 @@ jobs: done done - name: Gradle cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }}-ci @@ -132,8 +137,8 @@ jobs: APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk') APK=$(dirname $APK) echo "APK=$APK" >> $GITHUB_ENV - # - name: Upload Artifacts - # uses: actions/upload-artifact@v5 - # with: - # name: APKs - # path: ${{ env.APK }} + - name: Upload Artifacts + uses: actions/upload-artifact@v5 + with: + name: NekoBox-debug-apks + path: ${{ env.APK }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 8bac8a901..f6fbc7436 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -5,6 +5,8 @@ on: env: MIERU_VERSION: v3.34.0 HYSTERIA_VERSION: v2.9.2 + MDVPN_REF: android-vpnservice-protect-hook + MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 permissions: contents: read @@ -21,7 +23,7 @@ jobs: run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar @@ -41,14 +43,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -60,6 +62,9 @@ jobs: - name: Hysteria2 Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib hysteria2 + - name: MasterDnsVPN Build + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib masterdnsvpn - name: Naive Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive @@ -77,24 +82,24 @@ jobs: - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: sidecars-cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - for so in libmieru.so libhysteria2.so libnaive.so; do + for so in libmieru.so libhysteria2.so libmasterdnsvpn.so libnaive.so; do if [ ! -f "app/executableSo/$abi/$so" ]; then echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 exit 1 @@ -102,7 +107,7 @@ jobs: done done - name: Gradle cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5b4ff800..d9482cf00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,11 @@ on: env: MIERU_VERSION: v3.34.0 HYSTERIA_VERSION: v2.9.2 + MDVPN_REF: android-vpnservice-protect-hook + MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 permissions: - contents: write + contents: read jobs: libcore: name: Native Build (LibCore) @@ -27,7 +29,7 @@ jobs: run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar @@ -47,14 +49,14 @@ jobs: - name: Checkout uses: actions/checkout@v5 - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Install Golang if: steps.cache.outputs.cache-hit != 'true' uses: actions/setup-go@v6 @@ -66,6 +68,9 @@ jobs: - name: Hysteria2 Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib hysteria2 + - name: MasterDnsVPN Build + if: steps.cache.outputs.cache-hit != 'true' + run: ./run lib masterdnsvpn - name: Naive Download if: steps.cache.outputs.cache-hit != 'true' run: ./run lib naive @@ -83,24 +88,24 @@ jobs: - name: Libcore Status run: git ls-files libcore | xargs cat | sha1sum > libcore_status - name: LibCore Cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/libs/libcore.aar key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} - name: Sidecars Status - run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status + run: cat buildScript/lib/mieru.sh buildScript/lib/hysteria2.sh buildScript/lib/masterdnsvpn.sh buildScript/lib/naive.sh buildScript/init/env.sh buildScript/init/env_ndk.sh | sha1sum > sidecars_status - name: Sidecars Cache id: sidecars-cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | app/executableSo - key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.NAIVE_VERSION }}-sidecars + key: ${{ hashFiles('.github/workflows/*', 'sidecars_status') }}-${{ env.MIERU_VERSION }}-${{ env.HYSTERIA_VERSION }}-${{ env.MDVPN_COMMIT }}-${{ env.NAIVE_VERSION }}-sidecars - name: Verify Sidecar Artifacts run: | for abi in arm64-v8a armeabi-v7a x86 x86_64; do - for so in libmieru.so libhysteria2.so libnaive.so; do + for so in libmieru.so libhysteria2.so libmasterdnsvpn.so libnaive.so; do if [ ! -f "app/executableSo/$abi/$so" ]; then echo "Error: missing app/executableSo/$abi/$so (sidecar cache miss)" >&2 exit 1 @@ -108,7 +113,7 @@ jobs: done done - name: Gradle cache - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} @@ -132,6 +137,8 @@ jobs: name: Publish Release if: github.event.inputs.publish != 'y' runs-on: namespace-profile-nekoyay + permissions: + contents: write needs: build steps: - name: Checkout diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json index ab18a09fd..9b6fdacfd 100644 --- a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 9, - "identityHash": "d7954736b0004f47ab7d670c9699baa6", + "identityHash": "fd61bf62463150b8ff5acbfbe340ae87", "entities": [ { "tableName": "proxy_groups", @@ -76,7 +76,7 @@ }, { "tableName": "proxy_entities", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB, `awgBean` BLOB)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB, `masterDnsVpnBean` BLOB, `awgBean` BLOB)", "fields": [ { "fieldPath": "id", @@ -237,6 +237,11 @@ "columnName": "snellBean", "affinity": "BLOB" }, + { + "fieldPath": "masterDnsVpnBean", + "columnName": "masterDnsVpnBean", + "affinity": "BLOB" + }, { "fieldPath": "awgBean", "columnName": "awgBean", @@ -368,7 +373,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd7954736b0004f47ab7d670c9699baa6')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fd61bf62463150b8ff5acbfbe340ae87')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c61a5981..bda0d6c62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -134,6 +134,7 @@ + @@ -196,6 +197,9 @@ + diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 5da8b12dc..f8c82c6ab 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -133,6 +133,29 @@ object Key { const val SERVER_HY2_GECKO_MIN_PACKET = "serverHy2GeckoMinPacket" const val SERVER_HY2_GECKO_MAX_PACKET = "serverHy2GeckoMaxPacket" + // MasterDnsVPN + const val MDV_DOMAINS = "mdvDomains" + const val MDV_ENCRYPTION_METHOD = "mdvEncryptionMethod" + const val MDV_ENCRYPTION_KEY = "mdvEncryptionKey" + const val MDV_RESOLVERS = "mdvResolvers" + const val MDV_BALANCING_STRATEGY = "mdvBalancingStrategy" + const val MDV_PACKET_DUP = "mdvPacketDup" + const val MDV_SETUP_PACKET_DUP = "mdvSetupPacketDup" + const val MDV_AUTO_DISABLE_TIMEOUT = "mdvAutoDisableTimeout" + const val MDV_AUTO_REMOVE_LOW_MTU = "mdvAutoRemoveLowMtu" + const val MDV_BASE_ENCODE = "mdvBaseEncode" + const val MDV_UPLOAD_COMPRESSION = "mdvUploadCompression" + const val MDV_DOWNLOAD_COMPRESSION = "mdvDownloadCompression" + const val MDV_COMPRESSION_MIN_SIZE = "mdvCompressionMinSize" + const val MDV_MIN_UPLOAD_MTU = "mdvMinUploadMtu" + const val MDV_MIN_DOWNLOAD_MTU = "mdvMinDownloadMtu" + const val MDV_MAX_UPLOAD_MTU = "mdvMaxUploadMtu" + const val MDV_MAX_DOWNLOAD_MTU = "mdvMaxDownloadMtu" + const val MDV_LOCAL_DNS_ENABLED = "mdvLocalDnsEnabled" + const val MDV_LOCAL_DNS_PORT = "mdvLocalDnsPort" + const val MDV_LOG_LEVEL = "mdvLogLevel" + const val MDV_ADVANCED_JSON = "mdvAdvancedJson" + const val SERVER_PRIVATE_KEY = "serverPrivateKey" const val SERVER_INSECURE_CONCURRENCY = "serverInsecureConcurrency" diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt index 12596a288..4e08bbfe9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -214,6 +214,7 @@ class BaseService { suspend fun startProcesses() { data.proxy!!.launch() + data.proxy!!.awaitExternalProcessesReady() } fun startRunner() { 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 2d97a606b..941999d0f 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", "libhysteria2.so" + "libtrojan.so", "libtrojan-go.so", "libnaive.so", "libtuic.so", "libhysteria.so", "libmieru.so", "libhysteria2.so", "libmasterdnsvpn.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 59b54d311..4cf6ba5ff 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 @@ -7,10 +7,14 @@ import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.ConfigBuildResult +import io.nekohasekai.sagernet.fmt.LOCALHOST 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.masterdnsvpn.MasterDnsVpnBean +import io.nekohasekai.sagernet.fmt.masterdnsvpn.buildMasterDnsVpnConfig +import io.nekohasekai.sagernet.fmt.masterdnsvpn.resolverLines import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean @@ -24,6 +28,9 @@ import libcore.BoxInstance import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl import java.io.File +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket abstract class BoxInstance( val profile: ProxyEntity @@ -98,6 +105,13 @@ abstract class BoxInstance( } } } + + is MasterDnsVpnBean -> { + initPlugin("masterdnsvpn-plugin") + pluginConfigs[port] = profile.type to bean.buildMasterDnsVpnConfig( + port, File(app.noBackupFilesDir, "protect_path").absolutePath + ) + } } } } @@ -234,6 +248,37 @@ abstract class BoxInstance( processes.start(commands) } + + bean is MasterDnsVpnBean -> { + // Port is unique per chain entry, so two sidecars can't collide + // (unlike a bare elapsedRealtime() shared by the config+resolvers pair). + val ts = "${port}_${SystemClock.elapsedRealtime()}" + val configFile = File(cacheDir, "masterdnsvpn_$ts.json") + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val resolversFile = File(cacheDir, "masterdnsvpn_${ts}_resolvers.txt") + resolversFile.writeText(bean.resolverLines()) + cacheFiles.add(resolversFile) + + val commands = mutableListOf( + initPlugin("masterdnsvpn-plugin").path, + "-json", configFile.absolutePath, + "-resolvers", resolversFile.absolutePath, + ) + + // The bundled MasterDnsVPN client is a CGO/PIE Go binary. When it is + // launched in the VpnService process context, the Go runtime's + // asynchronous preemption (signal-based) can fault during the first + // protected upstream socket dial, terminating the sidecar before it + // opens its local SOCKS listener. Disabling async preemption avoids + // the fault; the sidecar workload is I/O-bound so scheduling latency + // is unaffected in practice. + val env = mutableMapOf("GODEBUG" to "asyncpreemptoff=1") + + processes.start(commands, env) + } } } } @@ -241,6 +286,54 @@ abstract class BoxInstance( box.start() } + /** + * Waits until every external sidecar's local SOCKS listener is accepting connections + * before the service reports Connected, so the sing-box socks outbound (and any + * connection test) doesn't race a sidecar that hasn't bound its port yet. + * + * Most sidecars open their listener immediately, so the short connection-test timeout + * is sufficient. MasterDnsVPN is the exception: it only starts listening after DNS + * MTU probing and session setup, which can take tens of seconds (with retries) on + * lossy or restricted links, so it gets a longer readiness window. + */ + suspend fun awaitExternalProcessesReady() { + if (!::processes.isInitialized || processes.processCount == 0) return + val ports = config.externalIndex.flatMap { it.chain.keys }.distinct() + if (ports.isEmpty()) return + + val hasMasterDnsVpn = config.externalIndex.any { idx -> + idx.chain.values.any { it.requireBean() is MasterDnsVpnBean } + } + val readinessTimeoutMs = if (hasMasterDnsVpn) { + maxOf(60_000L, DataStore.connectionTestTimeout.toLong()) + } else { + maxOf(1_000L, DataStore.connectionTestTimeout.toLong()) + } + + withContext(Dispatchers.IO) { + val deadline = SystemClock.elapsedRealtime() + readinessTimeoutMs + val pending = ports.toMutableSet() + while (pending.isNotEmpty() && SystemClock.elapsedRealtime() < deadline) { + val iterator = pending.iterator() + while (iterator.hasNext()) { + val port = iterator.next() + try { + Socket().use { + it.connect(InetSocketAddress(LOCALHOST, port), 100) + } + iterator.remove() + } catch (_: IOException) { + // not ready yet + } + } + if (pending.isNotEmpty()) delay(50) + } + if (pending.isNotEmpty()) { + throw IOException("sidecar listener not ready on port(s): ${pending.joinToString()}") + } + } + } + @Suppress("EXPERIMENTAL_API_USAGE") override fun close() { for (instance in externalInstances.values) { 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 07b77ede5..ab9348059 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -256,6 +256,29 @@ object DataStore : OnPreferenceDataStoreChangeListener { var serverHy2GeckoMinPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MIN_PACKET) { 512 } var serverHy2GeckoMaxPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MAX_PACKET) { 1200 } + // MasterDnsVPN + var mdvDomains by profileCacheStore.string(Key.MDV_DOMAINS) + var mdvEncryptionMethod by profileCacheStore.stringToInt(Key.MDV_ENCRYPTION_METHOD) { 0 } + var mdvEncryptionKey by profileCacheStore.string(Key.MDV_ENCRYPTION_KEY) + var mdvResolvers by profileCacheStore.string(Key.MDV_RESOLVERS) + var mdvBalancingStrategy by profileCacheStore.stringToInt(Key.MDV_BALANCING_STRATEGY) { 3 } + var mdvPacketDup by profileCacheStore.stringToInt(Key.MDV_PACKET_DUP) { 3 } + var mdvSetupPacketDup by profileCacheStore.stringToInt(Key.MDV_SETUP_PACKET_DUP) { 4 } + var mdvAutoDisableTimeout by profileCacheStore.boolean(Key.MDV_AUTO_DISABLE_TIMEOUT) + var mdvAutoRemoveLowMtu by profileCacheStore.boolean(Key.MDV_AUTO_REMOVE_LOW_MTU) + var mdvBaseEncode by profileCacheStore.boolean(Key.MDV_BASE_ENCODE) + var mdvUploadCompression by profileCacheStore.stringToInt(Key.MDV_UPLOAD_COMPRESSION) { 0 } + var mdvDownloadCompression by profileCacheStore.stringToInt(Key.MDV_DOWNLOAD_COMPRESSION) { 0 } + var mdvCompressionMinSize by profileCacheStore.stringToInt(Key.MDV_COMPRESSION_MIN_SIZE) { 120 } + var mdvMinUploadMtu by profileCacheStore.stringToInt(Key.MDV_MIN_UPLOAD_MTU) { 38 } + var mdvMinDownloadMtu by profileCacheStore.stringToInt(Key.MDV_MIN_DOWNLOAD_MTU) { 200 } + var mdvMaxUploadMtu by profileCacheStore.stringToInt(Key.MDV_MAX_UPLOAD_MTU) { 150 } + var mdvMaxDownloadMtu by profileCacheStore.stringToInt(Key.MDV_MAX_DOWNLOAD_MTU) { 4000 } + var mdvLocalDnsEnabled by profileCacheStore.boolean(Key.MDV_LOCAL_DNS_ENABLED) + var mdvLocalDnsPort by profileCacheStore.stringToInt(Key.MDV_LOCAL_DNS_PORT) { 53 } + var mdvLogLevel by profileCacheStore.string(Key.MDV_LOG_LEVEL) { "INFO" } + var mdvAdvancedJson by profileCacheStore.string(Key.MDV_ADVANCED_JSON) + 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/database/ProxyEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt index 64acca11e..a3ca8f033 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt @@ -11,6 +11,8 @@ import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.http.toUri import io.nekohasekai.sagernet.fmt.hysteria.* import io.nekohasekai.sagernet.fmt.internal.ChainBean +import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean +import io.nekohasekai.sagernet.fmt.masterdnsvpn.toUri as toMasterDnsVpnUri import io.nekohasekai.sagernet.fmt.mieru.MieruBean import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean @@ -82,6 +84,7 @@ data class ProxyEntity( var nekoBean: NekoBean? = null, var configBean: ConfigBean? = null, var snellBean: SnellBean? = null, + var masterDnsVpnBean: MasterDnsVpnBean? = null, var awgBean: AmneziaWGBean? = null, ) : Serializable() { @@ -105,6 +108,7 @@ data class ProxyEntity( const val TYPE_ANYTLS = 22 const val TYPE_JUICITY = 23 const val TYPE_SNELL = 24 + const val TYPE_MASTERDNSVPN = 25 // 25 is reserved for the MasterDnsVPN sidecar type on the // feature/masterdnsvpn-sidecar branch (PR #18); do not reuse it here so @@ -200,6 +204,7 @@ data class ProxyEntity( TYPE_NEKO -> nekoBean = KryoConverters.nekoDeserialize(byteArray) TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray) TYPE_SNELL -> snellBean = KryoConverters.snellDeserialize(byteArray) + TYPE_MASTERDNSVPN -> masterDnsVpnBean = KryoConverters.masterDnsVpnDeserialize(byteArray) TYPE_AWG -> awgBean = KryoConverters.amneziaWGDeserialize(byteArray) } } @@ -225,6 +230,7 @@ data class ProxyEntity( TYPE_NEKO -> nekoBean!!.displayType() TYPE_CONFIG -> configBean!!.displayType() TYPE_SNELL -> "Snell" + TYPE_MASTERDNSVPN -> "MasterDnsVPN" TYPE_AWG -> "AmneziaWG" else -> "Undefined type $type" } @@ -254,6 +260,7 @@ data class ProxyEntity( TYPE_NEKO -> nekoBean TYPE_CONFIG -> configBean TYPE_SNELL -> snellBean + TYPE_MASTERDNSVPN -> masterDnsVpnBean TYPE_AWG -> awgBean else -> error("Undefined type $type") } ?: error("Null ${displayType()} profile") @@ -293,6 +300,7 @@ data class ProxyEntity( is JuicityBean -> toUri() is AnyTLSBean -> toUri() is SnellBean -> toUri() + is MasterDnsVpnBean -> toMasterDnsVpnUri() is NekoBean -> "" else -> toUniversalLink() } @@ -344,6 +352,7 @@ data class ProxyEntity( TYPE_TROJAN_GO -> true TYPE_MIERU -> true TYPE_NAIVE -> true + TYPE_MASTERDNSVPN -> true TYPE_HYSTERIA -> !hysteriaBean!!.canUseSingBox() TYPE_NEKO -> true else -> false @@ -449,6 +458,8 @@ data class ProxyEntity( chainBean = null configBean = null nekoBean = null + snellBean = null + masterDnsVpnBean = null when (bean) { is SOCKSBean -> { @@ -541,6 +552,11 @@ data class ProxyEntity( snellBean = bean } + is MasterDnsVpnBean -> { + type = TYPE_MASTERDNSVPN + masterDnsVpnBean = bean + } + is ChainBean -> { type = TYPE_CHAIN chainBean = bean @@ -584,6 +600,7 @@ data class ProxyEntity( TYPE_CHAIN -> ChainSettingsActivity::class.java TYPE_CONFIG -> ConfigSettingActivity::class.java TYPE_SNELL -> SnellSettingsActivity::class.java + TYPE_MASTERDNSVPN -> MasterDnsVpnSettingsActivity::class.java else -> throw IllegalArgumentException() } ).apply { diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java index d1deffef5..5c8f70479 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java @@ -20,6 +20,7 @@ import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean; import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean; import io.nekohasekai.sagernet.fmt.snell.SnellBean; +import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean; import io.nekohasekai.sagernet.fmt.socks.SOCKSBean; import io.nekohasekai.sagernet.fmt.ssh.SSHBean; import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; @@ -176,6 +177,12 @@ public static SnellBean snellDeserialize(byte[] bytes) { return deserialize(new SnellBean(), bytes); } + @TypeConverter + public static MasterDnsVpnBean masterDnsVpnDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new MasterDnsVpnBean(), bytes); + } + @TypeConverter public static ChainBean chainDeserialize(byte[] bytes) { if (JavaUtil.isEmpty(bytes)) return null; diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt index 28e1bada0..0a3ecdd45 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt @@ -21,6 +21,7 @@ object TypeMap : HashMap() { this["juicity"] = ProxyEntity.TYPE_JUICITY this["anytls"] = ProxyEntity.TYPE_ANYTLS this["snell"] = ProxyEntity.TYPE_SNELL + this["masterdnsvpn"] = ProxyEntity.TYPE_MASTERDNSVPN this["neko"] = ProxyEntity.TYPE_NEKO this["config"] = ProxyEntity.TYPE_CONFIG } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnBean.java new file mode 100644 index 000000000..6ce992fa9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnBean.java @@ -0,0 +1,186 @@ +/****************************************************************************** + * Copyright (C) 2026 by nekohasekai * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + ******************************************************************************/ + +package io.nekohasekai.sagernet.fmt.masterdnsvpn; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +/** + * MasterDnsVPN (github.com/masterking32/MasterDnsVPN) DNS-tunneling client, run as a + * bundled native-binary sidecar. The carrier is DNS queries to the configured resolvers; + * on Android the upstream sockets are protected from the VPN via the forked client's + * FD_CONTROL_UNIX_SOCKET hook pointed at libcore's protect_path server. + */ +public class MasterDnsVpnBean extends AbstractBean { + + // --- Tunnel identity & security --- + public String domains; // newline/comma separated tunnel domains + public Integer dataEncryptionMethod; // 0 None,1 XOR,2 ChaCha20,3 AES-128,4 AES-192,5 AES-256 + public String encryptionKey; + public String resolvers; // one per line (e.g. 8.8.8.8, 1.1.1.1:5353) + + // --- Resolver selection / resilience --- + public Integer resolverBalancingStrategy; // 1..8 + public Integer packetDuplicationCount; // 1..10 + public Integer setupPacketDuplicationCount; + public Boolean autoDisableTimeoutServers; + public Boolean autoRemoveLowMtuServers; + public Boolean baseEncodeData; + + // --- Compression --- + public Integer uploadCompressionType; // 0 OFF,1 ZSTD,2 LZ4,3 ZLIB + public Integer downloadCompressionType; + public Integer compressionMinSize; + + // --- MTU discovery --- + public Integer minUploadMtu; + public Integer minDownloadMtu; + public Integer maxUploadMtu; + public Integer maxDownloadMtu; + + // --- Local DNS service --- + public Boolean localDnsEnabled; + public Integer localDnsPort; + + // --- Logging --- + public String logLevel; // e.g. "INFO", "DEBUG" + + // --- Escape hatch: raw JSON merged over the generated config --- + public String advancedJson; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (domains == null) domains = ""; + // Default to None: encryption must match the server and needs a key, so don't + // imply security with XOR + an empty key. The user picks a cipher + key explicitly. + if (dataEncryptionMethod == null) dataEncryptionMethod = 0; + if (encryptionKey == null) encryptionKey = ""; + if (resolvers == null) resolvers = "8.8.8.8\n1.1.1.1"; + if (resolverBalancingStrategy == null) resolverBalancingStrategy = 3; + if (packetDuplicationCount == null) packetDuplicationCount = 3; + if (setupPacketDuplicationCount == null) setupPacketDuplicationCount = 4; + if (autoDisableTimeoutServers == null) autoDisableTimeoutServers = true; + if (autoRemoveLowMtuServers == null) autoRemoveLowMtuServers = true; + if (baseEncodeData == null) baseEncodeData = false; + if (uploadCompressionType == null) uploadCompressionType = 0; + if (downloadCompressionType == null) downloadCompressionType = 0; + if (compressionMinSize == null) compressionMinSize = 120; + if (minUploadMtu == null) minUploadMtu = 38; + if (minDownloadMtu == null) minDownloadMtu = 200; + if (maxUploadMtu == null) maxUploadMtu = 150; + if (maxDownloadMtu == null) maxDownloadMtu = 4000; + if (localDnsEnabled == null) localDnsEnabled = false; + if (localDnsPort == null) localDnsPort = 53; + if (logLevel == null) logLevel = "INFO"; + if (advancedJson == null) advancedJson = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(domains); + output.writeInt(dataEncryptionMethod); + output.writeString(encryptionKey); + output.writeString(resolvers); + output.writeInt(resolverBalancingStrategy); + output.writeInt(packetDuplicationCount); + output.writeInt(setupPacketDuplicationCount); + output.writeBoolean(autoDisableTimeoutServers); + output.writeBoolean(autoRemoveLowMtuServers); + output.writeBoolean(baseEncodeData); + output.writeInt(uploadCompressionType); + output.writeInt(downloadCompressionType); + output.writeInt(compressionMinSize); + output.writeInt(minUploadMtu); + output.writeInt(minDownloadMtu); + output.writeInt(maxUploadMtu); + output.writeInt(maxDownloadMtu); + output.writeBoolean(localDnsEnabled); + output.writeInt(localDnsPort); + output.writeString(logLevel); + output.writeString(advancedJson); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + domains = input.readString(); + dataEncryptionMethod = input.readInt(); + encryptionKey = input.readString(); + resolvers = input.readString(); + resolverBalancingStrategy = input.readInt(); + packetDuplicationCount = input.readInt(); + setupPacketDuplicationCount = input.readInt(); + autoDisableTimeoutServers = input.readBoolean(); + autoRemoveLowMtuServers = input.readBoolean(); + baseEncodeData = input.readBoolean(); + uploadCompressionType = input.readInt(); + downloadCompressionType = input.readInt(); + compressionMinSize = input.readInt(); + minUploadMtu = input.readInt(); + minDownloadMtu = input.readInt(); + maxUploadMtu = input.readInt(); + maxDownloadMtu = input.readInt(); + localDnsEnabled = input.readBoolean(); + localDnsPort = input.readInt(); + logLevel = input.readString(); + advancedJson = input.readString(); + } + + @Override + public boolean canTCPing() { + return false; + } + + @Override + public boolean canMapping() { + // The sidecar connects directly to the configured DNS resolvers with sockets + // protected via FD_CONTROL_UNIX_SOCKET, so no dokodemo-door mapping is needed. + return false; + } + + @NotNull + @Override + public MasterDnsVpnBean clone() { + return KryoConverters.deserialize(new MasterDnsVpnBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public MasterDnsVpnBean newInstance() { + return new MasterDnsVpnBean(); + } + + @Override + public MasterDnsVpnBean[] newArray(int size) { + return new MasterDnsVpnBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnFmt.kt new file mode 100644 index 000000000..d63c739e9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnFmt.kt @@ -0,0 +1,279 @@ +/****************************************************************************** + * Copyright (C) 2026 by nekohasekai * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + ******************************************************************************/ + +package io.nekohasekai.sagernet.fmt.masterdnsvpn + +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.linkBuilder +import io.nekohasekai.sagernet.ktx.toStringPretty +import io.nekohasekai.sagernet.ktx.toLink +import io.nekohasekai.sagernet.ktx.urlSafe +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONArray +import org.json.JSONObject + +/** + * Builds the JSON config for the bundled MasterDnsVPN client (sidecar). + * + * The client is launched as `libmasterdnsvpn.so -json -resolvers `. + * Resolvers are written to a separate file (the client always loads resolvers from a + * file); see [resolverLines]. The config uses the client's uppercase config keys. + * + * Upstream resolver sockets are protected from the VPN via FD_CONTROL_UNIX_SOCKET, which + * points at libcore's protect_path server (relative socket in the process working dir). + * + * @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 MasterDnsVpnBean.buildMasterDnsVpnConfig(port: Int, protectPath: String): String { + // A tunnel with no domains can never connect; fail fast rather than emitting a config + // the sidecar will reject at runtime. + val domainList = domains.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() } + if (domainList.isEmpty()) { + error("MasterDnsVPN: at least one tunnel domain is required") + } + // Encryption (method != 0 == not None) requires a key that matches the server; emitting + // a blank ENCRYPTION_KEY would let the sidecar fail opaquely at runtime. Fail fast here. + if (dataEncryptionMethod != 0 && encryptionKey.isBlank()) { + error("MasterDnsVPN: encryption method $dataEncryptionMethod requires a non-empty key") + } + // The sidecar always loads upstream resolvers from the -resolvers file; an empty + // file leaves it with nowhere to forward queries, failing opaquely at runtime. + val resolverList = resolvers.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() } + if (resolverList.isEmpty()) { + error("MasterDnsVPN: at least one resolver is required") + } + val cfg = JSONObject().apply { + put("PROTOCOL_TYPE", "SOCKS5") + put("LISTEN_IP", LOCALHOST) + put("LISTEN_PORT", port) + // Loopback-only listener, no SOCKS auth (matches the other sidecars). + put("SOCKS5_AUTH", false) + + put("DOMAINS", JSONArray().apply { + domainList.forEach { put(it) } + }) + put("DATA_ENCRYPTION_METHOD", dataEncryptionMethod) + put("ENCRYPTION_KEY", encryptionKey) + + put("RESOLVER_BALANCING_STRATEGY", resolverBalancingStrategy) + put("PACKET_DUPLICATION_COUNT", packetDuplicationCount) + put("SETUP_PACKET_DUPLICATION_COUNT", setupPacketDuplicationCount) + put("AUTO_DISABLE_TIMEOUT_SERVERS", autoDisableTimeoutServers) + put("AUTO_REMOVE_LOW_MTU_SERVERS", autoRemoveLowMtuServers) + put("BASE_ENCODE_DATA", baseEncodeData) + + put("UPLOAD_COMPRESSION_TYPE", uploadCompressionType) + put("DOWNLOAD_COMPRESSION_TYPE", downloadCompressionType) + put("COMPRESSION_MIN_SIZE", compressionMinSize) + + put("MIN_UPLOAD_MTU", minUploadMtu) + put("MIN_DOWNLOAD_MTU", minDownloadMtu) + put("MAX_UPLOAD_MTU", maxUploadMtu) + put("MAX_DOWNLOAD_MTU", maxDownloadMtu) + + put("LOCAL_DNS_ENABLED", localDnsEnabled) + if (localDnsEnabled) { + put("LOCAL_DNS_IP", LOCALHOST) + put("LOCAL_DNS_PORT", localDnsPort) + } + + put("LOG_LEVEL", logLevel) + + // Android VPN socket protection. + put("FD_CONTROL_UNIX_SOCKET", protectPath) + } + + // Merge the advanced JSON override last so it can set/replace tuning keys, but never + // safety-critical ones: the protect socket and the loopback SOCKS listener must stay + // as generated, or VPN-mode socket protection / routing would silently break. + if (advancedJson.isNotBlank()) { + // Malformed override JSON must not crash config generation / VPN start. + try { + val protectedKeys = setOf( + "FD_CONTROL_UNIX_SOCKET", "LISTEN_IP", "LISTEN_PORT", + "PROTOCOL_TYPE", "SOCKS5_AUTH", + ) + val extra = JSONObject(advancedJson) + for (key in extra.keys()) { + if (key in protectedKeys) { + Logs.w("MasterDnsVPN: ignoring protected key '$key' in advanced JSON override") + continue + } + cfg.put(key, extra.get(key)) + } + } catch (e: Exception) { + Logs.w("MasterDnsVPN: ignoring invalid advanced JSON override: ${e.message}") + } + } + + return cfg.toStringPretty() +} + +/** Resolver entries to write into the client's resolvers file (one per line). */ +fun MasterDnsVpnBean.resolverLines(): String { + return resolvers.split("\n", ",").map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") +} + +/** + * Serializes a MasterDnsVPN profile to a shareable `masterdns://` link. + * + * Shape: `masterdns://@?#`. The encryption key + * is carried as URL userinfo; the first tunnel domain is used as a human-readable host + * (the protocol has no real server host). Comma-joined lists are used for the multi-value + * `domains` and `resolvers` fields. Only non-default tuning values are emitted to keep the + * link short; [parseMasterDnsVpn] re-applies defaults via [MasterDnsVpnBean.initializeDefaultValues]. + */ +fun MasterDnsVpnBean.toUri(): String { + val domainList = domains.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() } + if (domainList.isEmpty()) { + error("MasterDnsVPN: cannot build share link without a tunnel domain") + } + val host = domainList.first() + + val builder = linkBuilder() + .host(host) + .username(encryptionKey) + + builder.addQueryParameter("domains", domainList.joinToString(",")) + builder.addQueryParameter("enc", dataEncryptionMethod.toString()) + + val resolverList = resolvers.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() } + if (resolverList.isNotEmpty()) { + builder.addQueryParameter("resolvers", resolverList.joinToString(",")) + } + + val defaults = MasterDnsVpnBean().apply { initializeDefaultValues() } + if (resolverBalancingStrategy != defaults.resolverBalancingStrategy) { + builder.addQueryParameter("balance", resolverBalancingStrategy.toString()) + } + if (packetDuplicationCount != defaults.packetDuplicationCount) { + builder.addQueryParameter("dup", packetDuplicationCount.toString()) + } + if (setupPacketDuplicationCount != defaults.setupPacketDuplicationCount) { + builder.addQueryParameter("setupDup", setupPacketDuplicationCount.toString()) + } + if (autoDisableTimeoutServers != defaults.autoDisableTimeoutServers) { + builder.addQueryParameter("autoDisableTimeout", if (autoDisableTimeoutServers) "1" else "0") + } + if (autoRemoveLowMtuServers != defaults.autoRemoveLowMtuServers) { + builder.addQueryParameter("autoRemoveLowMtu", if (autoRemoveLowMtuServers) "1" else "0") + } + if (baseEncodeData != defaults.baseEncodeData) { + builder.addQueryParameter("baseEncode", if (baseEncodeData) "1" else "0") + } + if (uploadCompressionType != defaults.uploadCompressionType) { + builder.addQueryParameter("upComp", uploadCompressionType.toString()) + } + if (downloadCompressionType != defaults.downloadCompressionType) { + builder.addQueryParameter("downComp", downloadCompressionType.toString()) + } + if (compressionMinSize != defaults.compressionMinSize) { + builder.addQueryParameter("compMinSize", compressionMinSize.toString()) + } + if (minUploadMtu != defaults.minUploadMtu) { + builder.addQueryParameter("minUpMtu", minUploadMtu.toString()) + } + if (minDownloadMtu != defaults.minDownloadMtu) { + builder.addQueryParameter("minDownMtu", minDownloadMtu.toString()) + } + if (maxUploadMtu != defaults.maxUploadMtu) { + builder.addQueryParameter("maxUpMtu", maxUploadMtu.toString()) + } + if (maxDownloadMtu != defaults.maxDownloadMtu) { + builder.addQueryParameter("maxDownMtu", maxDownloadMtu.toString()) + } + if (localDnsEnabled != defaults.localDnsEnabled) { + builder.addQueryParameter("localDns", if (localDnsEnabled) "1" else "0") + } + if (localDnsPort != defaults.localDnsPort) { + builder.addQueryParameter("localDnsPort", localDnsPort.toString()) + } + if (logLevel != defaults.logLevel) { + builder.addQueryParameter("log", logLevel) + } + + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + + return builder.toLink("masterdns", appendDefaultPort = false) +} + +/** + * Parses a `masterdns://` share link into a [MasterDnsVpnBean]. Tolerant of missing optional + * params (defaults are filled by [MasterDnsVpnBean.initializeDefaultValues]). + */ +fun parseMasterDnsVpn(url: String): MasterDnsVpnBean { + val link = url.replace("masterdns://", "https://").toHttpUrlOrNull() + ?: error("invalid masterdns link $url") + return MasterDnsVpnBean().apply { + // serverAddress/Port are unused by this protocol; keep the placeholder. + serverAddress = "masterdnsvpn" + + encryptionKey = link.username + + link.queryParameter("domains")?.also { + domains = it.split(",").map { d -> d.trim() }.filter { d -> d.isNotEmpty() } + .joinToString("\n") + } + if (domains.isNullOrEmpty() && link.host.isNotBlank() && link.host != "masterdnsvpn") { + // Fall back to the host segment as the single tunnel domain. + domains = link.host + } + link.queryParameter("enc")?.toIntOrNull()?.also { dataEncryptionMethod = it } + link.queryParameter("resolvers")?.also { + resolvers = it.split(",").map { r -> r.trim() }.filter { r -> r.isNotEmpty() } + .joinToString("\n") + } + + link.queryParameter("balance")?.toIntOrNull()?.also { resolverBalancingStrategy = it } + link.queryParameter("dup")?.toIntOrNull()?.also { packetDuplicationCount = it } + link.queryParameter("setupDup")?.toIntOrNull()?.also { setupPacketDuplicationCount = it } + link.queryParameter("autoDisableTimeout")?.also { + autoDisableTimeoutServers = it == "1" || it == "true" + } + link.queryParameter("autoRemoveLowMtu")?.also { + autoRemoveLowMtuServers = it == "1" || it == "true" + } + link.queryParameter("baseEncode")?.also { baseEncodeData = it == "1" || it == "true" } + link.queryParameter("upComp")?.toIntOrNull()?.also { uploadCompressionType = it } + link.queryParameter("downComp")?.toIntOrNull()?.also { downloadCompressionType = it } + link.queryParameter("compMinSize")?.toIntOrNull()?.also { compressionMinSize = it } + link.queryParameter("minUpMtu")?.toIntOrNull()?.also { minUploadMtu = it } + link.queryParameter("minDownMtu")?.toIntOrNull()?.also { minDownloadMtu = it } + link.queryParameter("maxUpMtu")?.toIntOrNull()?.also { maxUploadMtu = it } + link.queryParameter("maxDownMtu")?.toIntOrNull()?.also { maxDownloadMtu = it } + link.queryParameter("localDns")?.also { localDnsEnabled = it == "1" || it == "true" } + link.queryParameter("localDnsPort")?.toIntOrNull()?.also { localDnsPort = it } + link.queryParameter("log")?.also { logLevel = it } + + name = link.fragment ?: "" + + // Ensure every field is non-null before any consumer (toUri / config builder / + // resolverLines) touches it; the import pipeline also calls this, but a freshly + // parsed bean may be read before that happens. + initializeDefaultValues() + + // A profile with no tunnel domain can never connect; fail the import early with a + // clear message instead of silently creating a broken entry. + if (domains.isBlank()) { + error("masterdns link missing tunnel domain(s)") + } + + // When encryption is enabled the key is mandatory (and must match the server). + // dataEncryptionMethod 0 == None, where an empty key is intentionally allowed. + if (dataEncryptionMethod != 0 && encryptionKey.isBlank()) { + error("masterdns link missing encryption key for encryption method $dataEncryptionMethod") + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt index d48e596c8..2b8ccffb6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -6,6 +6,7 @@ import io.nekohasekai.sagernet.fmt.Serializable import io.nekohasekai.sagernet.fmt.http.parseHttp import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria1 import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria2 +import io.nekohasekai.sagernet.fmt.masterdnsvpn.parseMasterDnsVpn import io.nekohasekai.sagernet.fmt.naive.parseNaive import io.nekohasekai.sagernet.fmt.parseUniversal import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks @@ -241,6 +242,13 @@ suspend fun parseProxies(text: String): List { }.onFailure { Logs.w(it) } + } else if (startsWith("masterdns://")) { + Logs.d("Try parse MasterDnsVPN link: $this") + runCatching { + entities.add(parseMasterDnsVpn(this)) + }.onFailure { + Logs.w(it) + } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt index d35ec0e48..2906b03a6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt @@ -74,6 +74,7 @@ object PluginManager { "hysteria-plugin" -> soIfExist("libhysteria.so") "hysteria2-plugin" -> soIfExist("libhysteria2.so") "mieru-plugin" -> soIfExist("libmieru.so") + "masterdnsvpn-plugin" -> soIfExist("libmasterdnsvpn.so") "naive-plugin" -> soIfExist("libnaive.so") else -> null } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 6635a5e10..332aff75e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -87,6 +87,7 @@ import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.ui.profile.ChainSettingsActivity import io.nekohasekai.sagernet.ui.profile.HttpSettingsActivity import io.nekohasekai.sagernet.ui.profile.HysteriaSettingsActivity +import io.nekohasekai.sagernet.ui.profile.MasterDnsVpnSettingsActivity import io.nekohasekai.sagernet.ui.profile.JuicitySettingsActivity import io.nekohasekai.sagernet.ui.profile.MieruSettingsActivity import io.nekohasekai.sagernet.ui.profile.NaiveSettingsActivity @@ -491,6 +492,10 @@ class ConfigurationFragment @JvmOverloads constructor( startActivity(Intent(requireActivity(), SnellSettingsActivity::class.java)) } + R.id.action_new_masterdnsvpn -> { + startActivity(Intent(requireActivity(), MasterDnsVpnSettingsActivity::class.java)) + } + R.id.action_new_wg -> { startActivity(Intent(requireActivity(), WireGuardSettingsActivity::class.java)) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/MasterDnsVpnSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/MasterDnsVpnSettingsActivity.kt new file mode 100644 index 000000000..4ba9ec3b9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/MasterDnsVpnSettingsActivity.kt @@ -0,0 +1,102 @@ +/****************************************************************************** + * Copyright (C) 2026 by nekohasekai * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + ******************************************************************************/ + +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean +import io.nekohasekai.sagernet.ktx.applyDefaultValues + +class MasterDnsVpnSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = MasterDnsVpnBean().applyDefaultValues() + + override fun MasterDnsVpnBean.init() { + DataStore.profileName = name + // serverAddress/Port are unused by this protocol; the editor uses the MDV_* fields. + DataStore.serverAddress = "masterdnsvpn" + DataStore.mdvDomains = domains + DataStore.mdvEncryptionMethod = dataEncryptionMethod + DataStore.mdvEncryptionKey = encryptionKey + DataStore.mdvResolvers = resolvers + DataStore.mdvBalancingStrategy = resolverBalancingStrategy + DataStore.mdvPacketDup = packetDuplicationCount + DataStore.mdvSetupPacketDup = setupPacketDuplicationCount + DataStore.mdvAutoDisableTimeout = autoDisableTimeoutServers + DataStore.mdvAutoRemoveLowMtu = autoRemoveLowMtuServers + DataStore.mdvBaseEncode = baseEncodeData + DataStore.mdvUploadCompression = uploadCompressionType + DataStore.mdvDownloadCompression = downloadCompressionType + DataStore.mdvCompressionMinSize = compressionMinSize + DataStore.mdvMinUploadMtu = minUploadMtu + DataStore.mdvMinDownloadMtu = minDownloadMtu + DataStore.mdvMaxUploadMtu = maxUploadMtu + DataStore.mdvMaxDownloadMtu = maxDownloadMtu + DataStore.mdvLocalDnsEnabled = localDnsEnabled + DataStore.mdvLocalDnsPort = localDnsPort + DataStore.mdvLogLevel = logLevel + DataStore.mdvAdvancedJson = advancedJson + } + + override fun MasterDnsVpnBean.serialize() { + name = DataStore.profileName + serverAddress = "masterdnsvpn" + serverPort = 0 + domains = DataStore.mdvDomains + dataEncryptionMethod = DataStore.mdvEncryptionMethod + encryptionKey = DataStore.mdvEncryptionKey + resolvers = DataStore.mdvResolvers + resolverBalancingStrategy = DataStore.mdvBalancingStrategy + packetDuplicationCount = DataStore.mdvPacketDup + setupPacketDuplicationCount = DataStore.mdvSetupPacketDup + autoDisableTimeoutServers = DataStore.mdvAutoDisableTimeout + autoRemoveLowMtuServers = DataStore.mdvAutoRemoveLowMtu + baseEncodeData = DataStore.mdvBaseEncode + uploadCompressionType = DataStore.mdvUploadCompression + downloadCompressionType = DataStore.mdvDownloadCompression + compressionMinSize = DataStore.mdvCompressionMinSize + minUploadMtu = DataStore.mdvMinUploadMtu + minDownloadMtu = DataStore.mdvMinDownloadMtu + maxUploadMtu = DataStore.mdvMaxUploadMtu + maxDownloadMtu = DataStore.mdvMaxDownloadMtu + localDnsEnabled = DataStore.mdvLocalDnsEnabled + localDnsPort = DataStore.mdvLocalDnsPort + logLevel = DataStore.mdvLogLevel + advancedJson = DataStore.mdvAdvancedJson + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.masterdnsvpn_preferences) + findPreference(Key.MDV_ENCRYPTION_KEY)!!.apply { + summaryProvider = PasswordSummaryProvider + } + for (numberKey in listOf( + Key.MDV_PACKET_DUP, Key.MDV_SETUP_PACKET_DUP, Key.MDV_COMPRESSION_MIN_SIZE, + Key.MDV_MIN_UPLOAD_MTU, Key.MDV_MIN_DOWNLOAD_MTU, Key.MDV_MAX_UPLOAD_MTU, + Key.MDV_MAX_DOWNLOAD_MTU, + )) { + findPreference(numberKey)?.setOnBindEditTextListener( + EditTextPreferenceModifiers.Number + ) + } + findPreference(Key.MDV_LOCAL_DNS_PORT)?.setOnBindEditTextListener( + EditTextPreferenceModifiers.Port + ) + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index b6b7766b1..fe6f63e49 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -137,6 +137,16 @@ private void applyHackConfig(Object src, JsonElement tree) { applyHackConfig(item, childTree.getAsJsonArray().get(index)); index++; } + } else if (child instanceof Map && childTree.isJsonObject()) { + JsonObject childObject = childTree.getAsJsonObject(); + for (Map.Entry entry : ((Map) child).entrySet()) { + Object value = entry.getValue(); + if (!(value instanceof SingBoxOption)) continue; + JsonElement valueTree = childObject.get(String.valueOf(entry.getKey())); + if (valueTree != null && !valueTree.isJsonNull()) { + applyHackConfig(value, valueTree); + } + } } } catch (IllegalAccessException ignored) { } diff --git a/app/src/main/res/menu/add_profile_menu.xml b/app/src/main/res/menu/add_profile_menu.xml index 4758d3e24..57f4d5513 100644 --- a/app/src/main/res/menu/add_profile_menu.xml +++ b/app/src/main/res/menu/add_profile_menu.xml @@ -72,6 +72,9 @@ + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index c6183e3e6..43a803f40 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -529,6 +529,55 @@ 2 + + None + XOR + ChaCha20 + AES-128-GCM + AES-192-GCM + AES-256-GCM + + + 0 + 1 + 2 + 3 + 4 + 5 + + + Random + Round Robin + Least Loss + Lowest Latency + Hybrid Score + Loss Then Latency + Least Loss Top Random + Least Loss Top Round Robin + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + + OFF + ZSTD + LZ4 + ZLIB + + + 0 + 1 + 2 + 3 + + 5 4 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42058d310..950898699 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -425,6 +425,32 @@ Obfuscation Type Gecko Min Packet Size Gecko Max Packet Size + MasterDnsVPN + Tunnel Domains + Encryption Method + Encryption Key + DNS Resolvers + Resolver & Resilience + Resolver Balancing Strategy + Packet Duplication Count + Setup Packet Duplication Count + Auto-disable Timeout Servers + Auto-remove Low-MTU Servers + Base-encode Data + Compression + Upload Compression + Download Compression + Compression Min Size + MTU Discovery + Min Upload MTU + Min Download MTU + Max Upload MTU + Max Download MTU + Misc + Local DNS Server + Local DNS Port + Log Level + Advanced JSON Override Authentication Type Authentication Payload Max Upload Speed (in Mbps) diff --git a/app/src/main/res/xml/masterdnsvpn_preferences.xml b/app/src/main/res/xml/masterdnsvpn_preferences.xml new file mode 100644 index 000000000..d67260653 --- /dev/null +++ b/app/src/main/res/xml/masterdnsvpn_preferences.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/buildScript/lib/masterdnsvpn.sh b/buildScript/lib/masterdnsvpn.sh new file mode 100755 index 000000000..9fb35cb14 --- /dev/null +++ b/buildScript/lib/masterdnsvpn.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Build the (forked) MasterDnsVPN client for all Android ABIs and install them as +# bundled native executables (app/executableSo//libmasterdnsvpn.so). +# +# Uses the hawkff fork branch that adds the Android FD_CONTROL_UNIX_SOCKET protect hook, +# so the client's upstream resolver sockets are protected from the VPN via libcore's +# protect_path server. PluginManager.initNativeInternal resolves +# "masterdnsvpn-plugin" -> libmasterdnsvpn.so. +# +# Requires Go 1.25+ on PATH (MasterDnsVPN's go.mod is go 1.25). libcore uses its own Go; +# in CI this build job gets its own setup-go. +# +# Usage: ./run lib masterdnsvpn +set -e +set -o pipefail + +source "buildScript/init/env.sh" + +if [ -z "$ANDROID_NDK_HOME" ]; then + echo "Error: ANDROID_NDK_HOME is not set (NDK required to cross-compile MasterDnsVPN)." >&2 + exit 1 +fi + +MDVPN_REPO="${MDVPN_REPO:-https://github.com/hawkff/MasterDnsVPN.git}" +# Pin to an immutable commit for reproducible sidecar builds. MDVPN_REF may be a +# branch (dev convenience) or a commit SHA; MDVPN_COMMIT, when set, overrides it +# and is fetched/checked out exactly. Default pins the known-good commit. +MDVPN_REF="${MDVPN_REF:-android-vpnservice-protect-hook}" +MDVPN_COMMIT="${MDVPN_COMMIT:-d481d72d4b86783a87d536c214d2c68cc4e9320e}" +MDVPN_FETCH="${MDVPN_COMMIT:-$MDVPN_REF}" + +DEPS="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" +if [ ! -d "$DEPS" ]; then + DEPS="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" +fi +if [ ! -d "$DEPS" ]; then + DEPS="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-arm64/bin" +fi +if [ ! -d "$DEPS" ]; then + echo "Error: NDK LLVM toolchain not found under $ANDROID_NDK_HOME." >&2 + exit 1 +fi + +WORK="$(pwd)/.masterdnsvpn-build" +OUT="$(pwd)/app/executableSo" + +need_clone=1 +if [ -d "$WORK/.git" ]; then + if git -C "$WORK" fetch --depth 1 origin "$MDVPN_FETCH" \ + && git -C "$WORK" checkout -q FETCH_HEAD; then + need_clone=0 + else + echo ">> existing $WORK is unusable; re-cloning" + fi +fi +if [ "$need_clone" -eq 1 ]; then + rm -rf "$WORK" + # Fetch the pinned commit (or branch) into a fresh repo. --branch does not + # accept a commit SHA, so fetch the ref explicitly and check out FETCH_HEAD. + git init -q "$WORK" + git -C "$WORK" remote add origin "$MDVPN_REPO" + git -C "$WORK" fetch --depth 1 origin "$MDVPN_FETCH" + git -C "$WORK" checkout -q FETCH_HEAD +fi + +pushd "$WORK" >/dev/null + +# MasterDnsVPN requires Go 1.25+. Fail fast with a clear message otherwise. +if ! command -v go >/dev/null 2>&1; then + echo "Error: go not found on PATH (MasterDnsVPN needs Go 1.25+)." >&2 + exit 1 +fi +GO_VER="$(go env GOVERSION 2>/dev/null | sed 's/^go//')" +GO_MAJOR="${GO_VER%%.*}" +GO_REST="${GO_VER#*.}"; GO_MINOR="${GO_REST%%.*}" +if [ "${GO_MAJOR:-0}" -lt 1 ] || { [ "${GO_MAJOR:-0}" -eq 1 ] && [ "${GO_MINOR:-0}" -lt 25 ]; }; then + echo "Error: Go $GO_VER is too old; MasterDnsVPN needs Go 1.25+." >&2 + exit 1 +fi + +build_abi() { + local abi="$1" goarch="$2" cc="$3" goarm="$4" + echo ">> building libmasterdnsvpn.so for $abi" + mkdir -p "$OUT/$abi" + env GOOS=android GOARCH="$goarch" ${goarm:+GOARM=$goarm} CGO_ENABLED=1 \ + CC="$DEPS/$cc" \ + go build -trimpath -ldflags='-s -w' \ + -o "$OUT/$abi/libmasterdnsvpn.so" ./cmd/client +} + +build_abi "arm64-v8a" "arm64" "aarch64-linux-android21-clang" +build_abi "armeabi-v7a" "arm" "armv7a-linux-androideabi21-clang" "7" +build_abi "x86" "386" "i686-linux-android21-clang" +build_abi "x86_64" "amd64" "x86_64-linux-android21-clang" + +popd >/dev/null + +echo ">> installed MasterDnsVPN binaries:" +ls -la "$OUT"/*/libmasterdnsvpn.so