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