diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000000..0ba3740f88 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,63 @@ +name: Regression checks +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-go@v5 + with: + go-version: '1.25.5' + cache-dependency-path: libcore/go.sum + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 + - name: Install Android toolchain + run: sdkmanager 'platforms;android-35' 'build-tools;35.0.1' 'ndk;25.0.8775105' + - name: Build libcore from verified sources + run: ./run lib core + - name: Prepare Geo assets + run: ./run init action gradle + - name: Android JVM regressions and compile classpath + run: | + ./gradlew -I app/src/test/phase2.init.gradle -I app/src/test/fixtures/classpath.init.gradle :app:testOssDebugUnitTest :app:writeFixtureClasspath + - name: Generate actual ConfigBuilder fixtures + run: | + python3 app/src/test/fixtures/legacy_geo_fixture.py \ + --repo "$PWD" --classpath-file app/build/fixture-classpath.txt \ + --android-jar "$ANDROID_HOME/platforms/android-35/android.jar" \ + --jdk "$JAVA_HOME" --output "$RUNNER_TEMP/neko-fixtures" + - name: Go core, DNS callbacks, Geo and generated config regressions + run: | + mkdir -p "$RUNNER_TEMP/neko-geo" + xz -dc app/src/main/assets/sing-box/geoip.db.xz > "$RUNNER_TEMP/neko-geo/geoip.db" + xz -dc app/src/main/assets/sing-box/geosite.db.xz > "$RUNNER_TEMP/neko-geo/geosite.db" + export NEKO_GEO_ASSETS="$RUNNER_TEMP/neko-geo" + export NEKO_CONFIG_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/configs" + export NEKO_REMOTE_CONFIG_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/remotes/configs" + export NEKO_LEGACY_GEO_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/geo-configs" + bash buildScript/test/core.sh + - name: Upload test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: regression-results + path: | + app/build/reports/tests/ + app/build/test-results/ + ${{ runner.temp }}/neko-fixtures/*.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7aecc1c058..e54505d53c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,13 +60,23 @@ jobs: with: path: ~/.gradle key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} + - name: Prepare fork release signing + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + run: | + test -n "$RELEASE_KEYSTORE_BASE64" + umask 077 + printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nekobox-release.p12" + echo "KEYSTORE_PATH=$RUNNER_TEMP/nekobox-release.p12" >> "$GITHUB_ENV" - name: Gradle Build env: BUILD_PLUGIN: none + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + ALIAS_NAME: ${{ secrets.ALIAS_NAME }} + ALIAS_PASS: ${{ secrets.ALIAS_PASS }} run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties - export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action gradle ./gradlew app:assembleOssRelease APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk') @@ -90,13 +100,13 @@ jobs: name: APKs path: artifacts - name: Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.tag }} run: | - wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.13.0/ghr_v0.13.0_linux_amd64.tar.gz - tar -xvf ghr.tar.gz - mv ghr*linux_amd64/ghr . mkdir apks find artifacts -name "*.apk" -exec cp {} apks \; - ./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks + gh release create "$RELEASE_TAG" apks/*.apk --verify-tag --draft --title "$RELEASE_TAG" play: name: Build Play Bundle if: github.event.inputs.play != 'y' @@ -124,14 +134,25 @@ jobs: - name: Checkout Library run: | git submodule update --init 'app/*' + - name: Prepare fork release signing + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + run: | + test -n "$RELEASE_KEYSTORE_BASE64" + umask 077 + printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nekobox-release.p12" + echo "KEYSTORE_PATH=$RUNNER_TEMP/nekobox-release.p12" >> "$GITHUB_ENV" - name: Gradle Build + env: + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + ALIAS_NAME: ${{ secrets.ALIAS_NAME }} + ALIAS_PASS: ${{ secrets.ALIAS_PASS }} run: | echo "sdk.dir=${ANDROID_HOME}" > local.properties echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties - export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" ./run init action gradle ./gradlew bundlePlayRelease - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: AAB path: app/build/outputs/bundle/playRelease/app-play-release.aab diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8ba7b0e2d..b7b3b017db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,7 +66,7 @@ dependencies { implementation("com.blacksquircle.ui:language-json:2.6.0") implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.3") - implementation("org.yaml:snakeyaml:1.30") + implementation("org.yaml:snakeyaml:2.7") implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01") implementation("com.jakewharton:process-phoenix:2.1.2") implementation("com.esotericsoftware:kryo:5.2.1") diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json new file mode 100644 index 0000000000..6137f9df48 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json @@ -0,0 +1,446 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "efdec71bbf810033c968aa3275c0592d", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ungrouped", + "columnName": "ungrouped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelector", + "columnName": "isSelector", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frontProxy", + "columnName": "frontProxy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "landingProxy", + "columnName": "landingProxy", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ping", + "columnName": "ping", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "httpBean", + "columnName": "httpBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssBean", + "columnName": "ssBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanBean", + "columnName": "trojanBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanGoBean", + "columnName": "trojanGoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "mieruBean", + "columnName": "mieruBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "naiveBean", + "columnName": "naiveBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "hysteriaBean", + "columnName": "hysteriaBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "tuicBean", + "columnName": "tuicBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "sshBean", + "columnName": "sshBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "wgBean", + "columnName": "wgBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "shadowTLSBean", + "columnName": "shadowTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "anyTLSBean", + "columnName": "anyTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "chainBean", + "columnName": "chainBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "nekoBean", + "columnName": "nekoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "configBean", + "columnName": "configBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL, `remoteRuleSetTags` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domains", + "columnName": "domains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePort", + "columnName": "sourcePort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "outbound", + "columnName": "outbound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteRuleSetTags", + "columnName": "remoteRuleSetTags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "remote_rule_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `tag` TEXT NOT NULL, `url` TEXT NOT NULL, `format` TEXT NOT NULL, `updateIntervalMinutes` INTEGER NOT NULL, `enabled` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updateIntervalMinutes", + "columnName": "updateIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_rule_sets_tag", + "unique": true, + "columnNames": [ + "tag" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_rule_sets_tag` ON `${TABLE_NAME}` (`tag`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "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, 'efdec71bbf810033c968aa3275c0592d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69818c852e..1789a22bdc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + @@ -275,9 +276,13 @@ + tools:ignore="ForegroundServicePermission"> + + @@ -344,4 +349,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt index fd037ad49b..b9319244f3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt @@ -23,6 +23,9 @@ class BootReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return + runOnDefaultDispatcher { SubscriptionUpdater.reconfigureUpdater() } 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 e760983dcd..fa86e3a9f8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -317,7 +317,11 @@ class BaseService { val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) this as Context if (profile == null) { // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 - data.notification = createNotification("") + try { + data.notification = createNotification("") + } catch (e: Exception) { + Logs.w(e) + } stopRunner(false, getString(R.string.profile_empty)) return Service.START_NOT_STICKY } @@ -400,4 +404,4 @@ class BaseService { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index 0ac84ac6a3..eb1902b8a9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -7,9 +7,9 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.os.Build import android.text.format.Formatter -import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.nekohasekai.sagernet.Action @@ -137,6 +137,8 @@ class ServiceNotification( Theme.apply(service) builder.color = service.getColorAttr(R.attr.colorPrimary) + // Enter the foreground before starting the proxy. Let startup handle failures. + show() service.registerReceiver(this, IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) @@ -144,7 +146,7 @@ class ServiceNotification( runOnMainDispatcher { updateActions() - show() + update() } } @@ -184,26 +186,18 @@ class ServiceNotification( } - private suspend fun show() = - useBuilder { - try { - if (Build.VERSION.SDK_INT >= 34) { - (service as Service).startForeground( - notificationId, - it.build(), - FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED - ) - } else { - (service as Service).startForeground(notificationId, it.build()) - } - } catch (e: Exception) { - Toast.makeText( - SagerNet.application, - "startForeground: $e", - Toast.LENGTH_LONG - ).show() - } + private fun show() { + val notification = builder.build() + if (Build.VERSION.SDK_INT >= 34) { + (service as Service).startForeground( + notificationId, notification, + if (service is VpnService) FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + else FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + } else { + (service as Service).startForeground(notificationId, notification) } + } private suspend fun update() = useBuilder { NotificationManagerCompat.from(service as Service).notify(notificationId, it.build()) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt index ba21dde44d..e23c64f1c8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -154,8 +154,7 @@ object ProfileManager { } suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity { - rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1 - rule.id = SagerDatabase.rulesDao.createRule(rule) + RemoteRuleSetManager.saveRoute(rule, insert = true) if (post) { ruleIterator { onAdd(rule) } } @@ -163,7 +162,7 @@ object ProfileManager { } suspend fun updateRule(rule: RuleEntity) { - SagerDatabase.rulesDao.updateRule(rule) + RemoteRuleSetManager.saveRoute(rule, insert = false) ruleIterator { onUpdated(rule) } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt new file mode 100644 index 0000000000..ff08246302 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt @@ -0,0 +1,17 @@ +package io.nekohasekai.sagernet.database + +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +internal object RemoteRuleSetDownload { + fun fetch(client: OkHttpClient, url: String, cache: File, validate: (File) -> Unit, rename: (File, File) -> Unit) { + // A subscription response must not redirect a direct-network request to another target. + val direct = client.newBuilder().followRedirects(false).followSslRedirects(false).build() + direct.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw RemoteRuleSetManager.DownloadFailure("HTTP ${response.code}") + val body = response.body ?: throw RemoteRuleSetManager.DownloadFailure("Empty HTTP response") + body.byteStream().use { RemoteRuleSetStore.replace(cache, it, validate, rename) } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt new file mode 100644 index 0000000000..cd11a12e88 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.* + +@Entity(tableName = "remote_rule_sets", indices = [Index(value = ["tag"], unique = true)]) +data class RemoteRuleSetEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var name: String = "", + var tag: String = "", + var url: String = "", + var format: String = "source", + var updateIntervalMinutes: Long = 1440, + var enabled: Boolean = true, +) { + @androidx.room.Dao + interface Dao { + @Query("SELECT * FROM remote_rule_sets ORDER BY id") + fun all(): List + @Query("SELECT * FROM remote_rule_sets WHERE id = :id") + fun get(id: Long): RemoteRuleSetEntity? + @Insert fun insert(entity: RemoteRuleSetEntity): Long + @Update fun update(entity: RemoteRuleSetEntity) + @Query("DELETE FROM remote_rule_sets WHERE id = :id") fun delete(id: Long) + @Query("DELETE FROM remote_rule_sets") fun reset() + @Insert fun insertAll(entities: List) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt new file mode 100644 index 0000000000..0ae8cf1c79 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt @@ -0,0 +1,214 @@ +package io.nekohasekai.sagernet.database + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.system.Os +import androidx.work.* +import androidx.work.multiprocess.RemoteWorkManager +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.Logs +import libcore.Libcore +import moe.matsuri.nb4a.SingBoxOptions.RuleSet +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.net.Proxy +import java.util.concurrent.TimeUnit +import java.util.concurrent.ConcurrentHashMap + +object RemoteRuleSetManager { + const val DOWNLOAD_CLIENT = "ruleset-fetch-direct" + private val root get() = File(SagerNet.application.filesDir, "remote-rule-sets") + private val dao get() = SagerDatabase.remoteRuleSetsDao + private fun cache(e: RemoteRuleSetEntity) = File(root, "${e.id}.${e.format}") + private val errors = ConcurrentHashMap() + private val downloadLock = Any() + fun updated(e: RemoteRuleSetEntity): Long = cache(e).lastModified() + fun error(id: Long): String { + errors[id]?.let { return it } + val info = try { + RemoteWorkManager.getInstance(SagerNet.application) + .getWorkInfos(WorkQuery.fromUniqueWorkNames(listOf("rule-set-$id"))).get(10, TimeUnit.SECONDS) + .maxByOrNull { it.outputData.getLong("completed", 0) } + } catch (_: Exception) { return "Background update status unavailable" } ?: return "" + val e = dao.get(id) ?: return "" + return if (info.outputData.getLong("completed", 0) > updated(e)) info.outputData.getString("error") ?: "" else "" + } + + // Main UI and VPN processes share the same files. Serialize updates, edits and deletion. + @Synchronized + private fun locked(block: () -> T): T { + check(root.isDirectory || root.mkdirs()) + return RandomAccessFile(File(root, ".lock"), "rw").use { file -> + file.channel.lock().use { block() } + } + } + + fun save(value: RemoteRuleSetEntity): Long = locked { + RemoteRuleSetStore.validate(value) + val old = if (value.id == 0L) null else dao.get(value.id) ?: error("Rule-set no longer exists") + require(dao.all().none { it.id != value.id && it.tag == value.tag }) { "Duplicate rule-set tag" } + if (old != null && old.tag != value.tag) ensureUnreferenced(old.tag) + if (!value.enabled) { + require(SagerDatabase.rulesDao.enabledRules().none { value.tag in RemoteRuleSetStore.tags(it.remoteRuleSetTags) }) { + "Rule-set is used by an enabled route; disable the route first" + } + } + SagerDatabase.instance.runInTransaction { + if (old == null) value.id = dao.insert(value) else dao.update(value) + } + errors.remove(value.id) + schedule(value, ExistingWorkPolicy.REPLACE, 0) + value.id + } + + private fun ensureUnreferenced(tag: String) { + require(SagerDatabase.rulesDao.allRules().none { tag in RemoteRuleSetStore.tags(it.remoteRuleSetTags) }) { + "Rule-set is referenced; remove route references first" + } + } + + fun delete(id: Long) = locked { + val entity = dao.get(id) ?: return@locked + ensureUnreferenced(entity.tag) + dao.delete(id) + RemoteWorkManager.getInstance(SagerNet.application).cancelUniqueWork("rule-set-$id") + listOf("source", "binary").forEach { format -> + val file = File(root, "$id.$format") + check(!file.exists() || file.delete()) { "Cannot remove rule-set cache" } + } + errors.remove(id) + } + + private fun schedule(e: RemoteRuleSetEntity, policy: ExistingWorkPolicy, delay: Long) { + val manager = RemoteWorkManager.getInstance(SagerNet.application) + if (!e.enabled) { + manager.cancelUniqueWork("rule-set-${e.id}") + return + } + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf("id" to e.id)) + .setInitialDelay(delay, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + manager.enqueueUniqueWork("rule-set-${e.id}", policy, request).get(10, TimeUnit.SECONDS) + } + + fun update(id: Long) = synchronized(downloadLock) { + check(root.isDirectory || root.mkdirs()) + RandomAccessFile(File(root, ".download.lock"), "rw").use { file -> + file.channel.lock().use { + root.listFiles { entry -> entry.name.startsWith("candidate-") && entry.name.endsWith(".tmp") } + ?.forEach { it.delete() } + performUpdate(id) + } + } + } + + private fun performUpdate(id: Long) { + val e = locked { dao.get(id) ?: error("Rule-set no longer exists") } + try { + download(e) + errors.remove(id) + } catch (exception: Exception) { + // Only controlled messages are stored/displayed; HTTP URLs and Core parse errors may contain secrets. + val message = if (exception is DownloadFailure) exception.message!! else "Rule-set update failed" + errors[id] = message + throw DownloadFailure(message) + } + } + + fun backgroundUpdate(id: Long, active: () -> Boolean): String { + if (!active() || dao.get(id)?.enabled != true) return "" + try { update(id) } catch (_: Exception) { /* The manager keeps a visible, sanitized error. */ } + locked { if (active()) dao.get(id)?.let { schedule(it, ExistingWorkPolicy.APPEND_OR_REPLACE, it.updateIntervalMinutes) } } + return errors[id] ?: "" + } + + private fun download(e: RemoteRuleSetEntity) { + val cm = SagerNet.application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = (listOfNotNull(SagerNet.underlyingNetwork) + cm.allNetworks).distinct().firstOrNull { + cm.getNetworkCapabilities(it)?.let { caps -> + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } == true + } ?: throw DownloadFailure("No direct network available") + val client = OkHttpClient.Builder().proxy(Proxy.NO_PROXY) + .socketFactory(network.socketFactory).dns { network.getAllByName(it).toList() } + .connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS) + .callTimeout(90, TimeUnit.SECONDS).followRedirects(false).followSslRedirects(false).build() + try { + RemoteRuleSetDownload.fetch(client, e.url, cache(e), { candidate -> validateContent(e, candidate) }) { from, to -> + locked { + val current = dao.get(e.id) + require(current != null && current.url == e.url && current.format == e.format) { "Subscription changed during update" } + Os.rename(from.absolutePath, to.absolutePath) + } + } + } finally { + client.connectionPool.evictAll() + client.dispatcher.executorService.shutdown() + } + } + + private fun validateContent(e: RemoteRuleSetEntity, candidate: File) { + val config = JSONObject().put("log", JSONObject().put("disabled", true)) + .put("route", JSONObject().put("rule_set", JSONArray().put(JSONObject() + .put("type", "local").put("tag", e.tag).put("format", e.format).put("path", candidate.absolutePath)))) + try { + Libcore.newSingBoxInstance(config.toString(), null).close() + } catch (_: Exception) { + throw DownloadFailure("Invalid rule-set content") + } + } + + fun config(rules: List): List = locked { + RemoteRuleSetStore.required(dao.all(), rules).map { e -> + require(cache(e).isFile) { "Rule-set ${e.tag} has no valid cache; update it first" } + RuleSet().apply { type = "local"; tag = e.tag; format = e.format; path = cache(e).absolutePath } + } + } + + fun saveRoute(rule: RuleEntity, insert: Boolean) = locked { + RemoteRuleSetStore.required(dao.all(), listOf(rule)) + if (insert) { + rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1 + rule.id = SagerDatabase.rulesDao.createRule(rule) + } else { + SagerDatabase.rulesDao.updateRule(rule) + } + } + + fun restore(entities: List) = restore(entities) {} + + fun restore(entities: List, replaceDatabase: () -> Unit) = locked { + val previous = dao.all() + SagerDatabase.instance.runInTransaction { + dao.reset() + dao.insertAll(entities) + replaceDatabase() + } + // Files and WorkManager must only change after the database transaction commits. + previous.forEach { e -> + runCatching { RemoteWorkManager.getInstance(SagerNet.application).cancelUniqueWork("rule-set-${e.id}") } + .onFailure { Logs.w(it) } + listOf("source", "binary").forEach { File(root, "${e.id}.$it").delete() } + errors.remove(e.id) + } + entities.forEach { e -> + runCatching { schedule(e, ExistingWorkPolicy.REPLACE, 0) }.onFailure { Logs.w(it) } + } + } + + class DownloadFailure(message: String) : Exception(message) +} + +class RemoteRuleSetWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { + override fun doWork(): Result { + val error = RemoteRuleSetManager.backgroundUpdate(inputData.getLong("id", 0)) { !isStopped } + return Result.success(workDataOf("error" to error, "completed" to System.currentTimeMillis())) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt new file mode 100644 index 0000000000..36e1a4f4fd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt @@ -0,0 +1,86 @@ +package io.nekohasekai.sagernet.database + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.URI +import org.json.JSONArray +import org.json.JSONObject + +/** Subscription data and cache operations, independent of Android networking. */ +object RemoteRuleSetStore { + fun tags(value: String): List = value.lineSequence().filter { it.isNotBlank() }.distinct().toList() + + fun validate(entity: RemoteRuleSetEntity) { + require(entity.id >= 0 && entity.name.isNotBlank()) { "Invalid rule-set name or ID" } + require(entity.tag.matches(Regex("[A-Za-z0-9_.-]+"))) { "Invalid rule-set tag" } + require(entity.format == "source" || entity.format == "binary") { "Invalid rule-set format" } + require(entity.updateIntervalMinutes in 1..(Long.MAX_VALUE / 60000)) { "Invalid update interval" } + val uri = try { URI(entity.url) } catch (_: Exception) { null } + require(uri != null && uri.scheme in listOf("https", "http") && !uri.host.isNullOrBlank() + && uri.userInfo == null && uri.fragment == null) { "Invalid rule-set URL" } + } + + fun validateReferences(entities: List, rules: List) { + entities.forEach(::validate) + require(entities.map { it.tag }.distinct().size == entities.size) { "Duplicate rule-set tag" } + val byTag = entities.associateBy { it.tag } + rules.forEach { rule -> tags(rule.remoteRuleSetTags).forEach { tag -> + val entity = byTag[tag] ?: error("Missing rule-set: $tag") + require(!rule.enabled || entity.enabled) { "Enabled rule references disabled rule-set: $tag" } + } } + } + + /** Only subscriptions used by enabled routes are required to start the proxy. */ + fun required(entities: List, rules: List): List { + val active = rules.filter { it.enabled } + val referenced = active.flatMap { tags(it.remoteRuleSetTags) }.toSet() + val required = entities.filter { it.tag in referenced } + validateReferences(required, active) + return required + } + + fun export(entities: List): JSONArray = JSONArray().apply { + entities.forEach { e -> put(JSONObject().apply { + put("id", e.id); put("name", e.name); put("tag", e.tag); put("url", e.url) + put("format", e.format); put("updateIntervalMinutes", e.updateIntervalMinutes); put("enabled", e.enabled) + }) } + } + + fun parse(array: JSONArray?): List { + val result = (0 until (array?.length() ?: 0)).map { index -> + val o = array!!.getJSONObject(index) + RemoteRuleSetEntity(o.getLong("id"), o.getString("name"), o.getString("tag"), + o.getString("url"), o.getString("format"), o.getLong("updateIntervalMinutes"), o.getBoolean("enabled")) + .also { validate(it); require(it.id > 0) { "Invalid rule-set ID" } } + } + require(result.map { it.id }.distinct().size == result.size) { "Duplicate rule-set ID" } + validateReferences(result, emptyList()) + return result + } + + /** The old valid file stays in place until validation and a same-directory rename succeed. */ + fun replace(cache: File, input: InputStream, validate: (File) -> Unit, rename: (File, File) -> Unit) { + check(cache.parentFile!!.isDirectory || cache.parentFile!!.mkdirs()) { "Cannot create rule-set cache" } + val candidate = File.createTempFile("candidate-", ".tmp", cache.parentFile) + try { + FileOutputStream(candidate).use { out -> + val buffer = ByteArray(32768) + var total = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > 64L * 1024 * 1024) throw IOException("Rule-set exceeds 64 MiB") + out.write(buffer, 0, count) + } + out.fd.sync() + } + validate(candidate) + rename(candidate, cache) + } finally { + candidate.delete() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt index 7d610c5e40..59bd01fd72 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt @@ -5,6 +5,7 @@ import androidx.room.* import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.ktx.app import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.IgnoredOnParcel @Entity(tableName = "rules") @Parcelize @@ -25,6 +26,10 @@ data class RuleEntity( var protocol: String = "", var outbound: Long = 0, var packages: Set = emptySet(), + // Keep the backup v1 Parcel byte layout unchanged; references use an optional JSON block. + @IgnoredOnParcel + @ColumnInfo(defaultValue = "''") + var remoteRuleSetTags: String = "", ) : Parcelable { fun displayName(): String { @@ -34,6 +39,7 @@ data class RuleEntity( fun mkSummary(): String { var summary = "" if (config.isNotBlank()) summary += "[config]\n" + if (remoteRuleSetTags.isNotBlank()) summary += "rule-set: ${remoteRuleSetTags.replace('\n', ',')}\n" if (domains.isNotBlank()) summary += "$domains\n" if (ip.isNotBlank()) summary += "$ip\n" if (source.isNotBlank()) summary += "src ip: $source\n" @@ -107,4 +113,4 @@ data class RuleEntity( } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt index ececc106eb..ea0ee18ae7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -15,12 +15,13 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class], - version = 6, + entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, RemoteRuleSetEntity::class], + version = 7, autoMigrations = [ AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), - AutoMigration(from = 5, to = 6) + AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7) ] ) @TypeConverters(value = [KryoConverters::class, GsonConverters::class]) @@ -45,11 +46,13 @@ abstract class SagerDatabase : RoomDatabase() { val groupDao get() = instance.groupDao() val proxyDao get() = instance.proxyDao() val rulesDao get() = instance.rulesDao() + val remoteRuleSetsDao get() = instance.remoteRuleSetsDao() } abstract fun groupDao(): ProxyGroup.Dao abstract fun proxyDao(): ProxyEntity.Dao abstract fun rulesDao(): RuleEntity.Dao + abstract fun remoteRuleSetsDao(): RemoteRuleSetEntity.Dao } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt new file mode 100644 index 0000000000..0ff71d9497 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt @@ -0,0 +1,175 @@ +package io.nekohasekai.sagernet.fmt + +import moe.matsuri.nb4a.SingBoxOptions.* +import moe.matsuri.nb4a.utils.Util +import java.net.URI + +/** Converts only generated objects, before their existing custom JSON overlays. */ +internal fun migrateBuiltInConfig(options: MyOptions) { + val earlyRules = mutableListOf() + fun inboundActions(tag: String, sniff: Boolean?, override: Boolean?, strategy: String?) { + if (sniff == true) earlyRules += Rule_DefaultOptions().apply { + inbound = listOf(tag) + action = "sniff" + if (override == true) _neko_sniff_override = true + } + if (!strategy.isNullOrEmpty()) earlyRules += Rule_DefaultOptions().apply { + inbound = listOf(tag) + action = "resolve" + this.strategy = strategy + } + } + options.inbounds.forEach { inbound -> + when (inbound) { + is Inbound_TunOptions -> { + inboundActions(inbound.tag, inbound.sniff, inbound.sniff_override_destination, inbound.domain_strategy) + inbound.sniff = null + inbound.sniff_override_destination = null + inbound.domain_strategy = null + inbound.address = inbound.inet4_address.orEmpty() + inbound.inet6_address.orEmpty() + inbound.inet4_address = null + inbound.inet6_address = null + if (inbound.endpoint_independent_nat == true) { + inbound.udp_mapping = "endpoint_independent" + inbound.udp_filtering = "endpoint_independent" + } + inbound.endpoint_independent_nat = null + } + is Inbound_MixedOptions -> { + inboundActions(inbound.tag, inbound.sniff, inbound.sniff_override_destination, inbound.domain_strategy) + inbound.sniff = null + inbound.sniff_override_destination = null + inbound.domain_strategy = null + } + } + } + options.route.rules = earlyRules + options.route.rules + + val strategies = options.dns.servers.associate { it.tag to it.strategy } + val rcodes = mutableMapOf() + options.dns.servers = options.dns.servers.filter { server -> + val address = server.address + if (address.startsWith("rcode://")) { + rcodes[server.tag] = when (address.removePrefix("rcode://")) { + "success" -> "NOERROR" + "format_error" -> "FORMERR" + "server_failure" -> "SERVFAIL" + "name_error" -> "NXDOMAIN" + "not_implemented" -> "NOTIMP" + "refused" -> "REFUSED" + else -> error("Unknown DNS rcode: $address") + } + false + } else { + migrateDnsServer(server, options.dns.fakeip) + true + } + } + options.dns.fakeip = null + val rules = options.dns.rules.filterNot { + it is DNSRule_DefaultOptions && it.outbound == listOf("any") && it.server == "dns-direct" + }.toMutableList() + rules.forEach { rule -> + if (rule is DNSRule_DefaultOptions) { + val rcode = rcodes[rule.server] + if (rcode != null) { + rule.action = "predefined" + rule.rcode = rcode + rule.server = null + rule.disable_cache = null + } else { + rule.strategy = strategies[rule.server]?.takeIf { it.isNotEmpty() } + } + } + } + // Legacy transport strategy also applied when the final transport was used. + strategies[options.dns.final_]?.takeIf { it.isNotEmpty() }?.let { strategy -> + rules += DNSRule_DefaultOptions().apply { + server = options.dns.final_ + this.strategy = strategy + } + } + options.dns.rules = rules + val directResolver = mutableMapOf("server" to "dns-direct") + strategies["dns-direct"]?.takeIf { it.isNotEmpty() }?.let { directResolver["strategy"] = it } + options.route.default_domain_resolver = directResolver + + val outbounds = mutableListOf() + val endpoints = mutableListOf() + options.outbounds.forEachIndexed { index, outbound -> + val generated = outbound._hack_config_map + if (generated.containsKey("domain_strategy")) { + val strategy = generated.remove("domain_strategy") as String + generated["domain_resolver"] = directResolver.toMutableMap().apply { + if (strategy.isNotEmpty()) put("strategy", strategy) + } + } else if (outbound is Outbound && outbound.type == "direct") { + // The removed outbound:any DNS rule covered direct/bypass too. + generated["domain_resolver"] = directResolver.toMutableMap() + } + if (outbound is Outbound_WireGuardOptions) { + val endpoint = Endpoint_WireGuardOptions().apply { + type = "wireguard" + tag = outbound.tag + address = outbound.local_address + private_key = outbound.private_key + mtu = outbound.mtu + peers = listOf(mutableMapOf( + "address" to outbound.server, + "port" to outbound.server_port, + "public_key" to outbound.peer_public_key, + "allowed_ips" to listOf("0.0.0.0/0", "::/0") + ).apply { + outbound.pre_shared_key?.takeIf { it.isNotEmpty() }?.let { put("pre_shared_key", it) } + outbound.reserved?.takeIf { it.isNotEmpty() }?.let { + put("reserved", Util.b64Decode(it).map { byte -> byte.toInt() and 255 }) + } + }) + _hack_config_map = generated + _hack_custom_config = outbound._hack_custom_config + } + endpoints += endpoint + if (index == 0 && options.route.final_.isNullOrEmpty()) { + options.route.final_ = generated["tag"] as? String ?: outbound.tag + } + } else outbounds += outbound + } + options.outbounds = outbounds + if (endpoints.isNotEmpty()) options.endpoints = endpoints +} + +private fun migrateDnsServer(server: DNSServerOptions, fakeIP: DNSFakeIPOptions?) { + val address = server.address + server.address = null + server.strategy = null + server.address_resolver?.takeIf { it.isNotEmpty() }?.let { resolver -> + server.domain_resolver = mutableMapOf("server" to resolver).apply { + server.address_strategy?.takeIf { it.isNotEmpty() }?.let { put("strategy", it) } + } + } + server.address_resolver = null + server.address_strategy = null + // The legacy remote DNS default dialer used the App's default outbound. + if (server.tag == "dns-remote" && server.detour.isNullOrEmpty()) server.detour = "proxy" + if (address == "local" || address.startsWith("local://")) { + server.type = "local" + return + } + if (address == "fakeip") { + server.type = "fakeip" + server.detour = null + server.domain_resolver = null + server.inet4_range = fakeIP?.inet4_range + server.inet6_range = fakeIP?.inet6_range + return + } + // Legacy UDP accepts a bare IPv6 literal as well as host:port. + val authority = if (!address.startsWith("[") && address.count { it == ':' } > 1) "[$address]" else address + val uri = URI(if (address.contains("://")) address else "udp://$authority") + server.type = uri.scheme + require(server.type in setOf("udp", "tcp", "tls", "https", "quic", "h3")) { "Unsupported DNS transport: $address" } + server.server = requireNotNull(uri.host) { "Invalid DNS address: $address" }.removePrefix("[").removeSuffix("]") + if (uri.port >= 0) server.server_port = uri.port + if (server.type in setOf("tls", "https", "quic", "h3")) server.tls = mapOf("enabled" to true) + if (server.type in setOf("https", "h3") && !uri.path.isNullOrEmpty()) server.path = uri.path +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 8fe0a2944c..f35919f952 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -7,6 +7,8 @@ import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.database.RemoteRuleSetManager +import io.nekohasekai.sagernet.database.RemoteRuleSetStore import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean import io.nekohasekai.sagernet.fmt.hysteria.buildSingBoxOutboundHysteriaBean @@ -124,6 +126,7 @@ fun buildConfig( } val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules() + val remoteRuleSets = if (forTest) emptyList() else RemoteRuleSetManager.config(extraRules) val extraProxies = if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule -> rule.outbound.takeIf { it > 0 && it != proxy.id } @@ -507,10 +510,17 @@ fun buildConfig( makeSingBoxRule(domainList, false) } if (rule.ip.isNotBlank()) { + val domainRuleSets = rule_set.orEmpty() makeSingBoxRule(rule.ip.listByLineOrComma(), true) + // Both converters reset rule_set; retain the domain address alternatives. + if (domainRuleSets.isNotEmpty()) { + rule_set = (domainRuleSets + rule_set.orEmpty()).distinct() + } } if (rule_set != null) generateRuleSet(rule_set, ruleSets) + val remoteTags = RemoteRuleSetStore.tags(rule.remoteRuleSetTags) + if (remoteTags.isNotEmpty()) rule_set = ((rule_set ?: emptyList()) + remoteTags).distinct() if (rule.port.isNotBlank()) { port = mutableListOf() @@ -548,6 +558,9 @@ fun buildConfig( return DNSRule_DefaultOptions().apply { if (uidList.isNotEmpty()) user_id = uidList domainList?.let { makeSingBoxRule(it) } + if (remoteTags.isNotEmpty()) { + rule_set = (rule_set.orEmpty() + remoteTags).distinct() + } } } @@ -596,6 +609,8 @@ fun buildConfig( if (ruleObj.outbound == TAG_BLOCK) { ruleObj.outbound = null ruleObj.action = "reject" + } else if (rule.remoteRuleSetTags.isNotBlank()) { + ruleObj.action = "route" } route.rules.add(ruleObj) route.rule_set.addAll(ruleSets) @@ -603,6 +618,7 @@ fun buildConfig( } } + route.rule_set.addAll(remoteRuleSets) // 对 rule_set tag 去重 if (route.rule_set != null) { route.rule_set = route.rule_set.distinctBy { it.tag } @@ -740,6 +756,7 @@ fun buildConfig( if (!forTest) _hack_custom_config = DataStore.globalCustomConfig }.let { + migrateBuiltInConfig(it) val configMap = it.asMap() Util.mergeJSON(configMap, proxy.requireBean().customConfigJson) ConfigBuildResult( diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index a7597999e6..452aaa1107 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -28,8 +28,10 @@ import org.ini4j.Ini import org.json.JSONArray import org.json.JSONObject import org.json.JSONTokener +import org.yaml.snakeyaml.LoaderOptions import org.yaml.snakeyaml.TypeDescription import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.SafeConstructor import org.yaml.snakeyaml.error.YAMLException import java.io.StringReader import androidx.core.net.toUri @@ -234,7 +236,7 @@ object RawUpdater : GroupUpdater() { try { - val yaml = Yaml().apply { + val yaml = Yaml(SafeConstructor(LoaderOptions())).apply { addTypeDescription(TypeDescription(String::class.java, "str")) }.loadAs(text, Map::class.java) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt index b439225f83..2a004738a3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt @@ -151,6 +151,12 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { }) } if (rule) { + put("remoteRuleSets", RemoteRuleSetStore.export(SagerDatabase.remoteRuleSetsDao.all())) + put("remoteRuleSetReferences", JSONObject().apply { + SagerDatabase.rulesDao.allRules().forEach { + if (it.remoteRuleSetTags.isNotBlank()) put(it.id.toString(), JSONArray(RemoteRuleSetStore.tags(it.remoteRuleSetTags))) + } + }) put("rules", JSONArray().apply { SagerDatabase.rulesDao.allRules().forEach { put(it.toBase64Str()) @@ -204,7 +210,8 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { it.bufferedReader().readText() }) } catch (e: Exception) { - Logs.w(e) + // JSON parser errors can embed the backup, including subscription URL tokens. + Logs.w("Invalid backup JSON") invalid() return } @@ -265,61 +272,84 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) { fun finishImport( content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean ) { - if (profile && content.has("profiles")) { - val profiles = mutableListOf() - val jsonProfiles = content.getJSONArray("profiles") - for (i in 0 until jsonProfiles.length()) { - val data = Util.b64Decode(jsonProfiles[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel)) - parcel.recycle() - } - SagerDatabase.proxyDao.reset() - SagerDatabase.proxyDao.insert(profiles) - - val groups = mutableListOf() - val jsonGroups = content.getJSONArray("groups") - for (i in 0 until jsonGroups.length()) { - val data = Util.b64Decode(jsonGroups[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - groups.add(ProxyGroup.CREATOR.createFromParcel(parcel)) - parcel.recycle() + val (remoteSets, parsedReferences) = try { + val sets = if (rule && content.has("rules")) { + RemoteRuleSetStore.parse(if (content.has("remoteRuleSets")) content.getJSONArray("remoteRuleSets") else null) + } else emptyList() + val references = if (rule && content.has("remoteRuleSetReferences")) content.getJSONObject("remoteRuleSetReferences") else JSONObject() + val parsed = references.keys().asSequence().associateWith { key -> + require(key.toLongOrNull() != null) + val array = references.getJSONArray(key) + (0 until array.length()).map { array.getString(it) }.also { tags -> + require(tags.all { tag -> sets.any { it.tag == tag } }) + }.joinToString("\n") } - SagerDatabase.groupDao.reset() - SagerDatabase.groupDao.insert(groups) + sets to parsed + } catch (_: Exception) { + throw IllegalArgumentException("Invalid remote rule-set backup data") } - if (rule && content.has("rules")) { - val rules = mutableListOf() - val jsonRules = content.getJSONArray("rules") - for (i in 0 until jsonRules.length()) { - val data = Util.b64Decode(jsonRules[i] as String) + fun parse(name: String, create: (Parcel) -> T): List { + val values = content.getJSONArray(name) + return (0 until values.length()).map { index -> + val data = Util.b64Decode(values.getString(index)) val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - rules.add(ParcelizeBridge.createRule(parcel)) - parcel.recycle() + try { + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + create(parcel) + } finally { + parcel.recycle() + } } - SagerDatabase.rulesDao.reset() - SagerDatabase.rulesDao.insert(rules) } - if (setting && content.has("settings")) { - val settings = mutableListOf() - val jsonSettings = content.getJSONArray("settings") - for (i in 0 until jsonSettings.length()) { - val data = Util.b64Decode(jsonSettings[i] as String) - val parcel = Parcel.obtain() - parcel.unmarshall(data, 0, data.size) - parcel.setDataPosition(0) - settings.add(KeyValuePair.CREATOR.createFromParcel(parcel)) - parcel.recycle() + + // Parse every selected section before changing either database or any cache. + val profiles = if (profile && content.has("profiles")) parse("profiles", ProxyEntity.CREATOR::createFromParcel) else null + val groups = if (profiles != null) parse("groups", ProxyGroup.CREATOR::createFromParcel) else null + val rules = if (rule && content.has("rules")) parse("rules") { parcel -> + ParcelizeBridge.createRule(parcel).apply { remoteRuleSetTags = parsedReferences[id.toString()] ?: "" } + } else null + val settings = if (setting && content.has("settings")) parse("settings", KeyValuePair.CREATOR::createFromParcel) else null + if (rules != null) { + require(parsedReferences.keys.all { id -> rules.any { it.id.toString() == id } }) { "Missing referenced route" } + RemoteRuleSetStore.validateReferences(remoteSets, rules) + } + + val database = SagerDatabase.instance + val sqlite = database.openHelper.writableDatabase + // Both Room databases use rollback journals. ATTACH makes their replacement one + // SQLite transaction, including a rollback if inserting settings fails. + if (settings != null) { + val settingsPath = PublicDatabase.instance.openHelper.writableDatabase.path + sqlite.execSQL("ATTACH DATABASE ? AS imported_settings", arrayOf(settingsPath)) + } + try { + val replace: () -> Unit = { + if (profiles != null) { + SagerDatabase.proxyDao.reset() + SagerDatabase.proxyDao.insert(profiles) + SagerDatabase.groupDao.reset() + SagerDatabase.groupDao.insert(groups!!) + } + if (rules != null) { + SagerDatabase.rulesDao.reset() + SagerDatabase.rulesDao.insert(rules) + } + if (settings != null) { + sqlite.execSQL("DELETE FROM imported_settings.KeyValuePair") + settings.forEach { value -> + sqlite.execSQL( + "INSERT INTO imported_settings.KeyValuePair (`key`, valueType, value) VALUES (?, ?, ?)", + arrayOf(value.key, value.valueType, value.value) + ) + } + } } - PublicDatabase.kvPairDao.reset() - PublicDatabase.kvPairDao.insert(settings) + if (rules != null) RemoteRuleSetManager.restore(remoteSets, replace) + else database.runInTransaction { replace() } + } finally { + if (settings != null) sqlite.execSQL("DETACH DATABASE imported_settings") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt new file mode 100644 index 0000000000..1856e46dc4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt @@ -0,0 +1,121 @@ +package io.nekohasekai.sagernet.ui + +import android.app.Dialog +import android.os.Bundle +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Date + +class RemoteRuleSetsDialog : DialogFragment() { + private var items = emptyList() + private lateinit var adapter: ArrayAdapter + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1) + parentFragmentManager.setFragmentResultListener("rule-set-saved", this) { _, _ -> refresh() } + return MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.remote_rule_sets) + .setAdapter(adapter) { _, index -> actions(items[index]) } + .setPositiveButton(R.string.ruleset_add) { _, _ -> editor(0) } + .setNegativeButton(android.R.string.cancel, null).create() + } + + override fun onStart() { + super.onStart() + val alert = dialog as AlertDialog + alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { editor(0) } + // AlertDialog item clicks normally dismiss; keep the management list available. + alert.listView.setOnItemClickListener { _, _, position, _ -> actions(items[position]) } + refresh() + } + + private fun refresh() { + lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + SagerDatabase.remoteRuleSetsDao.all().map { e -> + val updated = RemoteRuleSetManager.updated(e) + val state = if (updated == 0L) getString(R.string.ruleset_no_cache) + else DateFormat.getDateTimeInstance().format(Date(updated)) + e to "${e.name} (${e.tag})\n${getString(if (e.enabled) R.string.ruleset_enabled else R.string.ruleset_disabled)} · $state\n${RemoteRuleSetManager.error(e.id)}" + } + } + items = result.map { it.first } + adapter.clear(); adapter.addAll(result.map { it.second }) + } + } + + private fun editor(id: Long) { + RemoteRuleSetEditDialog().apply { arguments = Bundle().apply { putLong("id", id) } } + .show(parentFragmentManager, "rule-set-editor") + } + + private fun actions(e: RemoteRuleSetEntity) { + val choices = arrayOf(getString(R.string.ruleset_edit), getString(R.string.ruleset_update), + getString(if (e.enabled) R.string.ruleset_disable else R.string.ruleset_enable), getString(R.string.ruleset_delete)) + MaterialAlertDialogBuilder(requireContext()).setTitle(e.name).setItems(choices) { _, index -> + when (index) { + 0 -> editor(e.id) + 1 -> operation { RemoteRuleSetManager.update(e.id) } + 2 -> operation { RemoteRuleSetManager.save(e.copy(enabled = !e.enabled)) } + 3 -> MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.ruleset_delete) + .setMessage(e.name).setPositiveButton(android.R.string.ok) { _, _ -> operation { RemoteRuleSetManager.delete(e.id) } } + .setNegativeButton(android.R.string.cancel, null).show() + } + }.show() + } + + private fun operation(block: () -> Unit) { + lifecycleScope.launch { + val failure = withContext(Dispatchers.IO) { runCatching(block).exceptionOrNull() } + if (isAdded) { + Toast.makeText(requireContext(), failure?.message ?: getString(R.string.ruleset_done), Toast.LENGTH_LONG).show() + refresh() + } + } + } +} + +class RemoteRuleSetEditDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = layoutInflater.inflate(R.layout.layout_remote_rule_set_edit, null) + val id = requireArguments().getLong("id") + val entity = if (id == 0L) RemoteRuleSetEntity() else SagerDatabase.remoteRuleSetsDao.get(id) ?: RemoteRuleSetEntity() + val name = view.findViewById(R.id.ruleset_name) + val tag = view.findViewById(R.id.ruleset_tag) + val url = view.findViewById(R.id.ruleset_url) + val interval = view.findViewById(R.id.ruleset_interval) + val enabled = view.findViewById(R.id.ruleset_enabled) + val format = view.findViewById(R.id.ruleset_format) + name.setText(entity.name); tag.setText(entity.tag); url.setText(entity.url) + interval.setText(entity.updateIntervalMinutes.toString()); enabled.isChecked = entity.enabled + format.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, arrayOf("source", "binary")) + format.setSelection(if (entity.format == "binary") 1 else 0) + val alert = MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.remote_rule_sets) + .setView(view).setPositiveButton(android.R.string.ok, null).setNegativeButton(android.R.string.cancel, null).create() + alert.setOnShowListener { + alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val value = RemoteRuleSetEntity(id, name.text.toString().trim(), tag.text.toString().trim(), + url.text.toString().trim(), format.selectedItem.toString(), interval.text.toString().toLongOrNull() ?: 0, enabled.isChecked) + alert.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + lifecycleScope.launch { + val failure = withContext(Dispatchers.IO) { runCatching { RemoteRuleSetManager.save(value) }.exceptionOrNull() } + if (failure == null) { + parentFragmentManager.setFragmentResult("rule-set-saved", Bundle()) + dismiss() + } else if (isAdded) { + Toast.makeText(requireContext(), failure.message, Toast.LENGTH_LONG).show() + alert.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } + } + } + } + return alert + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt index 9fcf93e4aa..54ac0a91e2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt @@ -102,6 +102,7 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { + R.id.action_remote_rule_sets -> RemoteRuleSetsDialog().show(parentFragmentManager, "remote-rule-sets") R.id.action_new_route -> { startActivity(Intent(context, RouteSettingsActivity::class.java)) } @@ -287,13 +288,22 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem itemView.setOnClickListener { enableSwitch.performClick() } + enableSwitch.setOnCheckedChangeListener(null) + enableSwitch.isEnabled = true enableSwitch.isChecked = rule.enabled enableSwitch.setOnCheckedChangeListener { _, isChecked -> + val original = rule + val changed = original.copy(enabled = isChecked) + enableSwitch.isEnabled = false runOnDefaultDispatcher { - rule.enabled = isChecked - SagerDatabase.rulesDao.updateRule(rule) + val failure = runCatching { ProfileManager.updateRule(changed) }.exceptionOrNull() onMainDispatcher { - needReload() + if (rule.id == changed.id) bind(if (failure == null) changed else original) + if (failure != null) { + snackbar(failure.readableMessage).show() + } else { + needReload() + } } } } @@ -307,4 +317,4 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt index aaa062de30..1a56f363bf 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt @@ -19,6 +19,7 @@ import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceFragmentCompat +import androidx.preference.MultiSelectListPreference import com.github.shadowsocks.plugin.Empty import com.github.shadowsocks.plugin.fragment.AlertDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -74,6 +75,7 @@ class RouteSettingsActivity( else -> 3 } DataStore.routePackages = packages.joinToString("\n") + DataStore.profileCacheStore.putStringSet("routeRemoteRuleSets", io.nekohasekai.sagernet.database.RemoteRuleSetStore.tags(remoteRuleSetTags).toMutableSet()) } fun RuleEntity.serialize() { @@ -93,6 +95,7 @@ class RouteSettingsActivity( else -> DataStore.routeOutboundRule } packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.toSet() + remoteRuleSetTags = DataStore.profileCacheStore.getStringSet("routeRemoteRuleSets", mutableSetOf())!!.sorted().joinToString("\n") if (DataStore.editingId == 0L) { enabled = true @@ -112,6 +115,13 @@ class RouteSettingsActivity( addPreferencesFromResource(R.xml.route_preferences) editConfigPreference = findPreference(Key.SERVER_CONFIG)!! + val sets = SagerDatabase.remoteRuleSetsDao.all() + val selection = findPreference("routeRemoteRuleSets")!! + val selected = DataStore.profileCacheStore.getStringSet("routeRemoteRuleSets", mutableSetOf())!! + val tags = (sets.filter { it.enabled }.map { it.tag } + selected).distinct() + selection.entryValues = tags.toTypedArray() + selection.entries = tags.map { tag -> sets.find { it.tag == tag }?.let { "${it.name} ($tag)" } ?: tag }.toTypedArray() + selection.summaryProvider = Preference.SummaryProvider { it.values.sorted().joinToString(", ") } } override fun onResume() { @@ -266,22 +276,28 @@ class RouteSettingsActivity( return } - val editingId = DataStore.editingId - if (editingId == 0L) { - if (intent.hasExtra(EXTRA_PACKAGE_NAME)) { - setResult(RESULT_OK, Intent()) - } + try { + val editingId = DataStore.editingId + if (editingId == 0L) { + if (intent.hasExtra(EXTRA_PACKAGE_NAME)) { + setResult(RESULT_OK, Intent()) + } - ProfileManager.createRule(RuleEntity().apply { serialize() }) - } else { - val entity = SagerDatabase.rulesDao.getById(DataStore.editingId) - if (entity == null) { - finish() - return + ProfileManager.createRule(RuleEntity().apply { serialize() }) + } else { + val entity = SagerDatabase.rulesDao.getById(DataStore.editingId) + if (entity == null) { + finish() + return + } + ProfileManager.updateRule(entity.apply { serialize() }) } - ProfileManager.updateRule(entity.apply { serialize() }) + finish() + } catch (e: IllegalArgumentException) { + onMainDispatcher { Toast.makeText(this@RouteSettingsActivity, e.message, Toast.LENGTH_LONG).show() } + } catch (e: IllegalStateException) { + onMainDispatcher { Toast.makeText(this@RouteSettingsActivity, e.message, Toast.LENGTH_LONG).show() } } - finish() } @@ -391,4 +407,4 @@ class RouteSettingsActivity( } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index 2116c237ea..f6d37e8571 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.widget import android.annotation.SuppressLint import android.content.Context +import android.content.ContextWrapper import android.text.format.Formatter import android.util.AttributeSet import android.view.View @@ -80,8 +81,18 @@ class StatsBar @JvmOverloads constructor( TooltipCompat.setTooltipText(this, text) } + private fun requireActivity(): MainActivity { + // Material theme overlays can wrap the Activity in multiple ContextWrappers. + var current = context + while (current is ContextWrapper) { + if (current is MainActivity) return current + current = current.baseContext + } + error("StatsBar requires a MainActivity context") + } + fun changeState(state: BaseService.State) { - val activity = context as MainActivity + val activity = requireActivity() fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) { delay(100L) activity.whenStarted { what() } @@ -123,7 +134,7 @@ class StatsBar @JvmOverloads constructor( } fun testConnection() { - val activity = context as MainActivity + val activity = requireActivity() isEnabled = false setStatus(app.getText(R.string.connection_test_testing)) runOnDefaultDispatcher { @@ -146,7 +157,7 @@ class StatsBar @JvmOverloads constructor( Logs.w(e.toString()) onMainDispatcher { isEnabled = true - setStatus(app.getText(R.string.connection_test_testing)) + setStatus(app.getText(R.string.connection_test_failed)) activity.snackbar( app.getString( diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index 05f89275ff..995b232d85 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -113,6 +113,8 @@ public static class MyOptions extends SingBoxOption { public List outbounds; + public List endpoints; + public RouteOptions route; public ExperimentalOptions experimental; @@ -329,6 +331,15 @@ public static class DNSOptions extends SingBoxOption { public static class DNSServerOptions extends SingBoxOption { + public String type; + public String server; + public Integer server_port; + public Object domain_resolver; + public Object tls; + public String path; + public String inet4_range; + public String inet6_range; + public String tag; public String address; @@ -1046,6 +1057,8 @@ public static class TProxyInboundOptions extends SingBoxOption { public static class RouteOptions extends SingBoxOption { + public Object default_domain_resolver; + public List rules; public List rule_set; @@ -2799,6 +2812,10 @@ public static class WireGuardPeer extends SingBoxOption { public static class Inbound_TunOptions extends Inbound { + public List address; + public String udp_mapping; + public String udp_filtering; + public String interface_name; public Integer mtu; @@ -3851,6 +3868,13 @@ public static class Outbound_TrojanOptions extends Outbound { } + public static class Endpoint_WireGuardOptions extends Outbound { + public List address; + public String private_key; + public Integer mtu; + public List> peers; + } + public static class Outbound_WireGuardOptions extends Outbound { // Generate note: nested type DialerOptions @@ -4383,6 +4407,9 @@ public static class Outbound_URLTestOptions extends Outbound { public static class Rule_DefaultOptions extends Rule { + public String strategy; + public Boolean _neko_sniff_override; + // Generate note: Listable public List inbound; @@ -4460,6 +4487,10 @@ public static class Rule_DefaultOptions extends Rule { public static class DNSRule_DefaultOptions extends DNSRule { + public String action; + public String strategy; + public String rcode; + // Generate note: Listable public List inbound; diff --git a/app/src/main/res/layout/layout_about.xml b/app/src/main/res/layout/layout_about.xml index 7e677577dc..969354e27c 100644 --- a/app/src/main/res/layout/layout_about.xml +++ b/app/src/main/res/layout/layout_about.xml @@ -28,7 +28,7 @@ android:layout_marginRight="8dp" android:clickable="true" android:focusable="true" - app:cardElevation="2dp"> + app:cardElevation="0dp"> + app:cardElevation="0dp"> + android:layout_margin="8dp" + app:cardElevation="0dp"> + android:background="?attr/colorSurface"> + android:layout_margin="8dp" + app:cardElevation="0dp"> + android:background="?attr/colorSurface" + android:elevation="0dp"> + android:background="?attr/colorSurface"> + android:layout_margin="8dp" + app:cardElevation="0dp"> + app:cardElevation="0dp"> + app:cardElevation="0dp"> + android:layout_margin="8dp" + app:cardElevation="0dp"> + android:layout_margin="8dp" + app:cardElevation="0dp"> + android:padding="24dp"> + android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> diff --git a/app/src/main/res/layout/layout_logcat.xml b/app/src/main/res/layout/layout_logcat.xml index 1b950eb019..98bf5cbe44 100644 --- a/app/src/main/res/layout/layout_logcat.xml +++ b/app/src/main/res/layout/layout_logcat.xml @@ -14,7 +14,7 @@ + android:padding="16dp"> @@ -51,6 +52,7 @@ android:nextFocusUp="@+id/fab" app:backgroundTint="?colorMaterial300" app:contentInsetStart="0dp" + app:fabAnchorMode="cradle" app:hideOnScroll="true" app:layout_scrollFlags="enterAlways|scroll"> @@ -102,7 +104,7 @@ + app:cardElevation="0dp"> + app:cardElevation="0dp"> diff --git a/app/src/main/res/layout/layout_remote_rule_set_edit.xml b/app/src/main/res/layout/layout_remote_rule_set_edit.xml new file mode 100644 index 0000000000..1785a963e9 --- /dev/null +++ b/app/src/main/res/layout/layout_remote_rule_set_edit.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_route_item.xml b/app/src/main/res/layout/layout_route_item.xml index d92bdcc698..99ceb5b030 100644 --- a/app/src/main/res/layout/layout_route_item.xml +++ b/app/src/main/res/layout/layout_route_item.xml @@ -5,8 +5,8 @@ android:id="@+id/content" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_margin="4dp" - app:cardElevation="2dp"> + android:layout_margin="8dp" + app:cardElevation="0dp"> diff --git a/app/src/main/res/layout/layout_stun.xml b/app/src/main/res/layout/layout_stun.xml index 9a427af911..08b1eff87e 100644 --- a/app/src/main/res/layout/layout_stun.xml +++ b/app/src/main/res/layout/layout_stun.xml @@ -50,7 +50,7 @@ android:layout_marginRight="16dp" android:clickable="true" android:focusable="true" - app:cardElevation="2dp"> + app:cardElevation="0dp"> + app:cardElevation="0dp"> - \ No newline at end of file + + diff --git a/app/src/main/res/values-night-v23/status_bar.xml b/app/src/main/res/values-night-v23/status_bar.xml new file mode 100644 index 0000000000..3bec81833b --- /dev/null +++ b/app/src/main/res/values-night-v23/status_bar.xml @@ -0,0 +1,4 @@ + + @color/m3_surface + false + diff --git a/app/src/main/res/values-night/material3_colors.xml b/app/src/main/res/values-night/material3_colors.xml new file mode 100644 index 0000000000..b98c83ce0c --- /dev/null +++ b/app/src/main/res/values-night/material3_colors.xml @@ -0,0 +1,32 @@ + + #1C1B1F + #E6E1E5 + #302D33 + #CAC4D0 + #938F99 + #49454F + #1C1B1F + #171418 + #F2B8B5 + #EDABC1 + #EDABBE + #EDDDAB + #ABD0ED + #C3CFD5 + #D9C6BF + #ABE6ED + #EDBBAB + #C5B2E6 + #B8E0BA + #CCCCCC + #B3BBE5 + #ABD9ED + #CEE6B2 + #E7EDAB + #EDD2AB + #E3ACEC + #EDAFAB + #ABEDE7 + #EDE6AB + #CCCCCC + diff --git a/app/src/main/res/values-v23/status_bar.xml b/app/src/main/res/values-v23/status_bar.xml new file mode 100644 index 0000000000..bfb42ead7a --- /dev/null +++ b/app/src/main/res/values-v23/status_bar.xml @@ -0,0 +1,4 @@ + + @color/m3_surface + true + diff --git a/app/src/main/res/values-zh-rCN/remote_rule_sets.xml b/app/src/main/res/values-zh-rCN/remote_rule_sets.xml new file mode 100644 index 0000000000..3e0e4b16f0 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/remote_rule_sets.xml @@ -0,0 +1,19 @@ + + 远程规则集 + 新增订阅 + 编辑 + 立即更新 + 删除订阅 + 启用 + 禁用 + 已启用 + 已禁用 + 尚无有效缓存,请更新后再连接 + 操作完成 + 名称 + 唯一标签(字母、数字、. _ -) + 订阅 URL + 格式 + 更新间隔(分钟) + 固定使用直连网络下载。支持 source JSON / binary SRS,最大 64 MiB。Android 可能推迟定时更新;更新失败保留有效缓存。修改路由后请重新连接。 + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a9f0c1cba6..90017449e4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -59,6 +59,7 @@ \n直连: %3$s↑ %4$s↓ %s/s 测试中… + 连接测试失败 连接成功: HTTPS 握手耗时 %dms 失败: %s 网络不可用 @@ -499,4 +500,4 @@ 最小化 无法读取已安装的应用。\n这通常是由于系统限制了应用的读取权限(例如某些中国厂商系统)。\n请在系统设置中授予权限。 打开系统设置 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index de521bcbd5..af69b5f5e2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -58,6 +58,7 @@ \n直連: %3$s↑ %4$s↓ %s/s 正在測試… + 連線測試失敗 成功:HTTPS 握手花費 %dms 失敗:%s 網路不可用 diff --git a/app/src/main/res/values/material3_colors.xml b/app/src/main/res/values/material3_colors.xml new file mode 100644 index 0000000000..86ac31bffa --- /dev/null +++ b/app/src/main/res/values/material3_colors.xml @@ -0,0 +1,32 @@ + + #FFFBFE + #1C1B1F + #EDE7EC + #49454F + #79747E + #CAC4D0 + #FFFFFF + #211B1E + #B3261E + #931F47 + #931F40 + #695416 + #1D5A8B + #455A64 + #704F43 + #166069 + #933B1F + #4C2B88 + #2B632E + #595959 + #2E3B84 + #1B5F7E + #42601F + #565C13 + #765019 + #822092 + #93271F + #146059 + #605914 + #595959 + diff --git a/app/src/main/res/values/material3_styles.xml b/app/src/main/res/values/material3_styles.xml new file mode 100644 index 0000000000..6c8e15f643 --- /dev/null +++ b/app/src/main/res/values/material3_styles.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/app/src/main/res/values/remote_rule_sets.xml b/app/src/main/res/values/remote_rule_sets.xml new file mode 100644 index 0000000000..abbe63a2bb --- /dev/null +++ b/app/src/main/res/values/remote_rule_sets.xml @@ -0,0 +1,19 @@ + + Remote rule sets + Add subscription + Edit + Update now + Delete subscription + Enable + Disable + Enabled + Disabled + No valid cache; update before connecting + Completed + Name + Unique tag (letters, digits, . _ -) + Subscription URL + Format + Update interval (minutes) + Downloads always use a direct network. Source JSON / binary SRS, up to 64 MiB. Android may delay scheduled updates. Existing valid caches survive failed updates. Route changes require reconnecting. + diff --git a/app/src/main/res/values/status_bar.xml b/app/src/main/res/values/status_bar.xml new file mode 100644 index 0000000000..71ebda843e --- /dev/null +++ b/app/src/main/res/values/status_bar.xml @@ -0,0 +1,4 @@ + + @color/m3_system_bar + false + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1995ff0513..6f38b6c274 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,7 @@ Proxy: %1$s↑ %2$s↓\nDirect: %3$s↑ %4$s↓ %s/s Testing… + Connection test failed Success: HTTPS handshake took %dms Success: HTTP handshake took %dms Failed: %s @@ -579,4 +580,4 @@ Minimize Unable to read installed apps.\nThis is usually because the system has restricted app read permissions.\nPlease grant permissions in the system settings. Open System Settings - \ No newline at end of file + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index ca9afcb9c7..9cca97dfcd 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,16 +1,19 @@ - - - + + - - + + - - - - - \ No newline at end of file + diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml index 987c09cb99..7ba4a13108 100644 --- a/app/src/main/res/xml/backup_descriptor.xml +++ b/app/src/main/res/xml/backup_descriptor.xml @@ -2,6 +2,9 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index f18d8df7cc..d489c2c085 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -2,6 +2,9 @@ + + + @@ -10,6 +13,9 @@ path="configuration.db" /> + + + diff --git a/app/src/main/res/xml/route_preferences.xml b/app/src/main/res/xml/route_preferences.xml index 42c60ba0b0..6e43eb3f59 100644 --- a/app/src/main/res/xml/route_preferences.xml +++ b/app/src/main/res/xml/route_preferences.xml @@ -12,6 +12,9 @@ app:title="@string/custom_config" app:useSimpleSummaryProvider="true" /> + + p.afterEvaluate { + if (p.path == ':app') { + p.tasks.register('writeFixtureClasspath') { + dependsOn 'compileOssDebugJavaWithJavac' + doLast { + def output = new File(p.buildDir, 'fixture-classpath.txt') + output.parentFile.mkdirs() + def artifacts = p.configurations.ossDebugCompileClasspath.incoming.artifactView { + attributes.attribute(org.gradle.api.attributes.Attribute.of('artifactType', String), 'android-classes-jar') + } + output.text = artifacts.files.collect { it.absolutePath }.join('\n') + } + } + } + } +} diff --git a/app/src/test/fixtures/legacy_geo_README.md b/app/src/test/fixtures/legacy_geo_README.md new file mode 100644 index 0000000000..2d55e903d3 --- /dev/null +++ b/app/src/test/fixtures/legacy_geo_README.md @@ -0,0 +1,70 @@ +# Legacy GeoSite + GeoIP regression + +This fixes a BROKEN-BASELINE predating Phase 1 and Phase 2. Both calls to +`makeSingBoxRule` reset `rule_set`. The IP call discarded the GeoSite tags before +`generateRuleSet`, while DNS still referenced them. + +The legacy model has domain/IP address fields and no logical-mode field. Its +converter creates one default rule. Both the former Matsuri matcher and official +sing-box v1.14.0 put domain and destination IP conditions in the same address +group (OR). Rule-set alternatives are also OR; port, network, protocol, source +and user constraints still apply to the entire rule. This is not an AND between +GeoSite and GeoIP. See official `route/rule/rule_abstract.go` (`evaluateGroups`) +and `route/rule/rule_item_rule_set.go` (`matchWithOuterGroups`). The App's existing +Geo compatibility loader supplies one address-only headless rule per Geo tag. + +The production change saves domain tags before IP conversion and restores them +in the address alternatives before definitions are generated. DNS generation, +actions, custom config, Room, UI and libcore are unchanged. + +## Actual App fixtures + +Compile `:app:compileOssDebugJavaWithJavac` using the project's existing build +toolchain. Supply a text file containing one absolute resolved compile dependency +JAR/AAR path per line (Gradle `ossDebugCompileClasspath.files`). For example, a +temporary audit init script can register a task printing these paths; no production +Gradle change is needed. + +```text +python app/src/test/fixtures/legacy_geo_fixture.py --repo REPO --classpath-file CLASSPATH.txt --android-jar ANDROID_SDK/platforms/android-35/android.jar --jdk JDK17 --output AUDIT +``` + +The runner executes the actual compiled ConfigBuilder, formatter and RuleEntity. +Only Android platform/DAO access is stubbed. It produces the original 37 Phase 1 +fixtures, four Phase 2 fixtures and 68 Geo fixtures. The Geo matrix includes single +and multiple tags, DNS routing/FakeIP toggles, BYPASS/PROXY/BLOCK/specific-node, +DIRECT custom override, constraints, invert and Remote Rule Set coexistence. + +`LegacyGeoRuleDatabaseTest` separately verifies the real Room DAO excludes disabled +mixed rules and retains their data. Run with the existing test-only init script: + +```text +gradlew -I app/src/test/phase2.init.gradle :app:testOssDebugUnitTest +``` + +## Core contracts + +Run `core/legacy_geo_rule_test.go` in the same isolated libcore host harness as +the existing Phase 1/2 tests (the explicit production file list, unchanged +`compat_host_test.go`, `dns_callback_test.go`, `remote_rule_set_test.go` and the +platform-specific replacement helper). Set these absolute paths: + +```text +NEKO_CONFIG_FIXTURES=AUDIT/config-fixture/configs +NEKO_REMOTE_CONFIG_FIXTURES=AUDIT/config-fixture/remotes/configs +NEKO_LEGACY_GEO_FIXTURES=AUDIT/config-fixture/geo-configs +NEKO_GEO_ASSETS=VERIFIED_GEO_DATABASE_DIRECTORY +``` + +The harness uses official Core commit `0b8995879f29a9b98ee027bc17b75e101445b238` +without modification. Geo tests check all route/DNS tag references, actual Core +creation, domain-only/IP-only/both/neither matches, IPv4/IPv6, second Geo values, +invert and negative tests for each extra constraint. Matching starts the actual +rule object without opening Android inbounds. It is not an Android device test. + +Before the fix, the original 64-case Geo matrix had 32 single-field passes and +32 mixed-field failures, including missing GeoSite DNS definitions. Afterward, +the original 37 configurations are identical, the four Phase 2 configurations +are identical after normalizing only fixture cache directories, all 32 single +Geo configurations are identical, and mixed configurations differ only by the +restored GeoSite tags/definitions. Android device tests remain deferred. diff --git a/app/src/test/fixtures/legacy_geo_fixture.py b/app/src/test/fixtures/legacy_geo_fixture.py new file mode 100644 index 0000000000..66badf730d --- /dev/null +++ b/app/src/test/fixtures/legacy_geo_fixture.py @@ -0,0 +1,233 @@ +from pathlib import Path +import argparse, json, os, subprocess +parser=argparse.ArgumentParser(description="Actual ConfigBuilder Geo and Phase 1/2 regression fixtures") +parser.add_argument('--repo', type=Path, required=True) +parser.add_argument('--classpath-file', type=Path, required=True) +parser.add_argument('--android-jar', type=Path, required=True) +parser.add_argument('--jdk', type=Path, required=True) +parser.add_argument('--output', type=Path, required=True) +args=parser.parse_args() +NEKO=args.repo.resolve(); LOG=args.output.resolve(); JAVA=args.jdk.resolve() +LOG.mkdir(parents=True,exist_ok=True) +def run(name, command, cwd): + p=subprocess.run([str(x) for x in command], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (LOG/(name+'.log')).write_bytes(p.stdout) + print(p.stdout.decode('utf-8',errors='replace')) + return p.returncode + +import zipfile + +FIX=LOG/'config-fixture' +SRC=FIX/'src' +CLASSES=FIX/'classes' +JARS=FIX/'jars' +for path in [SRC,CLASSES,JARS]: path.mkdir(parents=True,exist_ok=True) +cp=[CLASSES, NEKO/'app/build/tmp/kotlin-classes/ossDebug',NEKO/'app/build/intermediates/javac/ossDebug/compileOssDebugJavaWithJavac/classes',args.android_jar.resolve()] +for index,path in enumerate(args.classpath_file.read_text().splitlines()): + path=Path(path) + if path.suffix=='.aar': + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if name.endswith('.jar'): + target=JARS/(str(index)+'-'+Path(name).name) + target.write_bytes(z.read(name));cp.append(target) + else: cp.append(path) + +def source(name,text): + file=SRC/(name.replace('.','/')+'.java');file.parent.mkdir(parents=True,exist_ok=True);file.write_text(text,encoding='utf-8') + +source('io.nekohasekai.sagernet.database.DataStore','''package io.nekohasekai.sagernet.database; +public class DataStore { + public static final DataStore INSTANCE=new DataStore(); + public static int ipv6=1,sniff=2; public static boolean fake=true,resolve=true,dnsRouting=true; + public static String remote="https://dns.example/dns-query",direct="1.1.1.1",custom=""; + public String getServiceMode(){return "vpn";} + public boolean getAllowAccess(){return false;} + public String getRemoteDns(){return remote;} + public String getDirectDns(){return direct;} + public boolean getEnableDnsRouting(){return dnsRouting;} + public boolean getEnableFakeDns(){return fake;} + public int getTrafficSniffing(){return sniff;} + public int getIpv6Mode(){return ipv6;} + public boolean getEnableClashAPI(){return false;} + public boolean getGlobalAllowInsecure(){return false;} + public int getLogLevel(){return 2;} + public int getTunImplementation(){return 1;} + public int getMtu(){return 1500;} + public boolean getResolveDestination(){return resolve;} + public int getMixedPort(){return 2080;} + public boolean getBypassLanInCore(){return true;} + public String getGlobalCustomConfig(){return custom;} + public io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore getConfigurationStore(){return new io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore();} +} +''') +source('io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore','''package io.nekohasekai.sagernet.database.preference; +public class RoomPreferenceDataStore { + public String getString(String key){return key.equals("domain_strategy_for_server")?"prefer_ipv4":"";} +} +''') +source('io.nekohasekai.sagernet.database.SagerDatabase','''package io.nekohasekai.sagernet.database; +import java.util.*; +import java.lang.reflect.Proxy; +public class SagerDatabase { + public static final Companion Companion=new Companion(); + public static ProxyGroup group=new ProxyGroup(); + public static List entities=new ArrayList<>(); + public static List rules=new ArrayList<>(); + public static class Companion { + public ProxyGroup.Dao getGroupDao(){return (ProxyGroup.Dao)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{ProxyGroup.Dao.class},(o,m,a)->m.getName().equals("getById")?group:null);} + public ProxyEntity.Dao getProxyDao(){return (ProxyEntity.Dao)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{ProxyEntity.Dao.class},(o,m,a)->{ + if(m.getName().equals("getByGroup"))return entities; + if(m.getName().equals("getById"))return entities.stream().filter(e->e.getId()==(Long)a[0]).findFirst().orElse(null); + if(m.getName().equals("getEntities"))return entities.stream().filter(e->((List)a[0]).contains(e.getId())).collect(java.util.stream.Collectors.toList()); + throw new UnsupportedOperationException(m.toString()); + });} + public RuleEntity.Dao getRulesDao(){return (RuleEntity.Dao)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{RuleEntity.Dao.class},(o,m,a)->rules);} + } +} +''') +source('android.util.Base64','''package android.util; +public class Base64 { + public static final int DEFAULT=0,NO_PADDING=1,NO_WRAP=2,CRLF=4,URL_SAFE=8; + public static byte[] decode(String s,int flags){return java.util.Base64.getMimeDecoder().decode(s);} + public static byte[] encode(byte[] s,int flags){return java.util.Base64.getEncoder().encode(s);} + public static String encodeToString(byte[] s,int flags){return java.util.Base64.getEncoder().encodeToString(s);} +} +''') +# UtilsKt initializes Android-only Socket reflection before this pure helper. +# Match its one-line implementation; all formatter and migration code is real. +source('io.nekohasekai.sagernet.ktx.UtilsKt','''package io.nekohasekai.sagernet.ktx; +public class UtilsKt { public static String blankAsNull(String value){return value==null||kotlin.text.StringsKt.isBlank(value)?null:value;} } +''') +source('ConfigFixture','''import io.nekohasekai.sagernet.database.*; +import io.nekohasekai.sagernet.fmt.*; +import io.nekohasekai.sagernet.fmt.socks.*; +import io.nekohasekai.sagernet.fmt.wireguard.*; +import java.nio.file.*; +public class ConfigFixture { + static void protocol(Path output,String name,int type,String beanClass,String setter,Object[][] fields)throws Exception { + Class cls=Class.forName(beanClass);AbstractBean bean=(AbstractBean)cls.getConstructor().newInstance();bean.initializeDefaultValues();bean.name=name;bean.serverAddress="127.0.0.1";bean.serverPort=443; + for(Object[] field:fields)cls.getField((String)field[0]).set(bean,field[1]); + ProxyEntity proxy=new ProxyEntity();proxy.setId(100);proxy.setGroupId(1);proxy.setType(type);ProxyEntity.class.getMethod(setter,cls).invoke(proxy,bean); + Files.writeString(output.resolve("protocol-"+name+".json"),ConfigBuilderKt.buildConfig(proxy,false,false).getConfig()); + } + public static void main(String[] args)throws Exception { + Path output=Paths.get(args[0]);Files.createDirectories(output); + SOCKSBean bean=new SOCKSBean();bean.initializeDefaultValues();bean.name="fixture";bean.serverAddress="127.0.0.1";bean.serverPort=1080; + ProxyEntity proxy=new ProxyEntity();proxy.setId(1);proxy.setGroupId(1);proxy.setType(0);proxy.setSocksBean(bean);SagerDatabase.entities.add(proxy); + for(int ip=0;ip<4;ip++)for(int sniff=0;sniff<3;sniff++){ + DataStore.ipv6=ip;DataStore.sniff=sniff; + Files.writeString(output.resolve("socks-ip"+ip+"-sniff"+sniff+".json"),ConfigBuilderKt.buildConfig(proxy,false,false).getConfig()); + } + Files.writeString(output.resolve("socks-urltest.json"),ConfigBuilderKt.buildConfig(proxy,true,false).getConfig()); + String[] dnsAddresses={"8.8.8.8:5353","2001:4860:4860::8888","[2001:4860:4860::8888]:5353","udp://1.1.1.1","tcp://1.1.1.1:5353","tls://dns.example","quic://dns.example","https://dns.example:8443/custom-path","h3://dns.example/dns-query","local","local://"}; + for(int i=0;i sets = new ArrayList<>();\n public java.util.List config(java.util.List rules) { return sets; }\n}\n') + +argfile=FIX/'javac.args' +args=['-classpath',os.pathsep.join(str(p) for p in cp),'-d',str(CLASSES)]+[str(p) for p in SRC.rglob('*.java')] +argfile.write_text('\n'.join('"'+str(a).replace('\\','/')+'"' for a in args),encoding='utf-8') +executable_suffix = '.exe' if os.name == 'nt' else '' +assert run('config-fixture-compile',[JAVA/('bin/javac'+executable_suffix),'@'+str(argfile)],FIX)==0 +argfile=FIX/'java.args' +args=['-classpath',os.pathsep.join(str(p) for p in cp),'ConfigFixture',str(FIX/'configs')] +argfile.write_text('\n'.join('"'+str(a).replace('\\','/')+'"' for a in args),encoding='utf-8') +result = run('actual-config-builder',[JAVA/('bin/java'+executable_suffix),'@'+str(argfile)],FIX) +if result: + raise SystemExit(result) +# A remote-only route must produce matching DNS conditions for direct, proxy and block. +for outbound in [0, -1, -2]: + config = json.loads((FIX/'remotes/configs'/f'remote-outbound-{outbound}.json').read_text()) + assert any(set(rule.get('rule_set', [])) == {'first', 'second'} + for rule in config['dns']['rules']), f'Missing remote DNS conditions: {outbound}' +print('Remote-only routes preserve rule-set conditions in DNS rules') diff --git a/app/src/test/java/io/nekohasekai/sagernet/BootReceiverTest.java b/app/src/test/java/io/nekohasekai/sagernet/BootReceiverTest.java new file mode 100644 index 0000000000..cdc37b5c9f --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/BootReceiverTest.java @@ -0,0 +1,23 @@ +package io.nekohasekai.sagernet; + +import android.app.Application; +import android.content.Intent; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import static org.junit.Assert.*; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = Application.class, manifest = Config.NONE) +public class BootReceiverTest { + @Test public void unrelatedExplicitIntentsAreIgnoredBeforeReadingSettings() { + Application context = RuntimeEnvironment.getApplication(); + BootReceiver receiver = new BootReceiver(); + receiver.onReceive(context,new Intent("other.app.START").setClass(context,BootReceiver.class)); + receiver.onReceive(context,new Intent().setClass(context,BootReceiver.class)); + assertNull(shadowOf(context).getNextStartedService()); + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/ServiceNotificationTest.java b/app/src/test/java/io/nekohasekai/sagernet/bg/ServiceNotificationTest.java new file mode 100644 index 0000000000..3d2251113e --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/ServiceNotificationTest.java @@ -0,0 +1,61 @@ +package io.nekohasekai.sagernet.bg; + +import android.app.Application; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.pm.ServiceInfo; +import io.nekohasekai.sagernet.SagerNet; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowService; +import org.robolectric.util.ReflectionHelpers; +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {34, 35}, application = Application.class, manifest = Config.NONE) +public class ServiceNotificationTest { + @Before public void setup() { + SagerNet app = new SagerNet(); + ReflectionHelpers.callInstanceMethod(app,"attach",ReflectionHelpers.ClassParameter.from( + Context.class,RuntimeEnvironment.getApplication())); + SagerNet.Companion.setApplication(app); + NotificationManager manager = app.getSystemService(NotificationManager.class); + manager.createNotificationChannel(new NotificationChannel("service-proxy","Proxy",NotificationManager.IMPORTANCE_LOW)); + manager.createNotificationChannel(new NotificationChannel("service-vpn","VPN",NotificationManager.IMPORTANCE_LOW)); + } + + @Test public void proxyUsesSpecialUseWhileVpnUsesSystemExemption() { + ProxyService proxy = Robolectric.buildService(ProxyService.class).create().get(); + ServiceNotification proxyNotification = proxy.createNotification("fixture"); + try { assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,proxy.getForegroundServiceType()); } + finally { proxyNotification.destroy(); } + VpnService vpn = Robolectric.buildService(VpnService.class).create().get(); + ServiceNotification vpnNotification = vpn.createNotification("fixture"); + try { assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,vpn.getForegroundServiceType()); } + finally { vpnNotification.destroy(); } + } + + @Implements(Service.class) + public static class RejectForeground extends ShadowService { + @Implementation protected void startForeground(int id, Notification notification, int type) { + throw new SecurityException("Foreground denied"); + } + } + + @Test @Config(shadows = RejectForeground.class) + public void foregroundFailureReachesTheStartupHandler() { + ProxyService service = Robolectric.buildService(ProxyService.class).create().get(); + try { service.createNotification("fixture"); fail("foreground failure was swallowed"); } + catch (SecurityException expected) { assertEquals("Foreground denied",expected.getMessage()); } + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/database/LegacyGeoRuleDatabaseTest.java b/app/src/test/java/io/nekohasekai/sagernet/database/LegacyGeoRuleDatabaseTest.java new file mode 100644 index 0000000000..48813b1c4b --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/database/LegacyGeoRuleDatabaseTest.java @@ -0,0 +1,30 @@ +package io.nekohasekai.sagernet.database; + +import android.app.Application; +import androidx.room.Room; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {21,28}, application = Application.class, manifest = Config.NONE) +public class LegacyGeoRuleDatabaseTest { + @Test public void disabledMixedRuleIsExcludedFromConfigBuilderQuery() { + SagerDatabase db = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), SagerDatabase.class) + .allowMainThreadQueries().build(); + try { + RuleEntity rule = new RuleEntity(); rule.setId(99); rule.setDomains("geosite:github"); + rule.setIp("geoip:us"); rule.setEnabled(false); rule.setOutbound(-2); + db.rulesDao().createRule(rule); + assertTrue(db.rulesDao().enabledRules(true).isEmpty()); + assertEquals(1, db.rulesDao().allRules().size()); + rule.setEnabled(true); db.rulesDao().updateRule(rule); + RuleEntity selected = db.rulesDao().enabledRules(true).get(0); + assertEquals("geosite:github", selected.getDomains()); + assertEquals("geoip:us", selected.getIp()); assertEquals(-2,selected.getOutbound()); + } finally { db.close(); } + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/database/RemoteRuleSetTest.java b/app/src/test/java/io/nekohasekai/sagernet/database/RemoteRuleSetTest.java new file mode 100644 index 0000000000..e850a20a55 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/database/RemoteRuleSetTest.java @@ -0,0 +1,343 @@ +package io.nekohasekai.sagernet.database; + +import android.app.Application; +import android.database.sqlite.SQLiteDatabase; +import android.os.Parcel; +import androidx.room.Room; +import org.json.*; +import org.junit.*; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {21, 28}, application = Application.class, manifest = Config.NONE) +public class RemoteRuleSetTest { + private static String readText(Path path) throws IOException { return new String(Files.readAllBytes(path), java.nio.charset.StandardCharsets.UTF_8); } + private final RemoteRuleSetStore store = RemoteRuleSetStore.INSTANCE; + private RemoteRuleSetEntity entity(long id, String tag) { + return new RemoteRuleSetEntity(id, "Fixture", tag, "https://example.invalid/rules.json", "source", 1440, true); + } + + @Test public void roomUpgradePreservesOldRowsAndSupportsCrud() throws Exception { + Application context = RuntimeEnvironment.getApplication(); + File file = context.getDatabasePath("migration-test"); + file.getParentFile().mkdirs(); + JSONObject schema = new JSONObject(readText(Paths.get(System.getProperty("phase2.schemaDir"), "6.json"))).getJSONObject("database"); + SQLiteDatabase legacy = SQLiteDatabase.openOrCreateDatabase(file, null); + JSONArray tables = schema.getJSONArray("entities"); + for (int i = 0; i < tables.length(); i++) { + JSONObject table = tables.getJSONObject(i); + legacy.execSQL(table.getString("createSql").replace("${TABLE_NAME}", table.getString("tableName"))); + JSONArray indices = table.getJSONArray("indices"); + for (int j = 0; j < indices.length(); j++) legacy.execSQL(indices.getJSONObject(j).getString("createSql").replace("${TABLE_NAME}", table.getString("tableName"))); + } + for (int i = 0; i < schema.getJSONArray("setupQueries").length(); i++) legacy.execSQL(schema.getJSONArray("setupQueries").getString(i)); + legacy.execSQL("INSERT INTO rules (id,name,config,userOrder,enabled,domains,ip,port,sourcePort,network,source,protocol,outbound,packages) VALUES (42,'existing','',7,1,'example.invalid','','','','','','',-1,'existing.app')"); + legacy.execSQL("INSERT INTO proxy_groups (id,userOrder,ungrouped,name,type,subscription,`order`,isSelector,frontProxy,landingProxy) VALUES (9,3,0,'Existing group',0,NULL,0,1,-1,-1)"); + io.nekohasekai.sagernet.fmt.socks.SOCKSBean bean = new io.nekohasekai.sagernet.fmt.socks.SOCKSBean(); + bean.initializeDefaultValues(); bean.name="Existing node"; bean.serverAddress="example.invalid"; bean.serverPort=1080; + byte[] encoded = io.nekohasekai.sagernet.fmt.KryoConverters.serialize(bean); + legacy.execSQL("INSERT INTO proxy_entities (id,groupId,type,userOrder,tx,rx,status,ping,uuid,socksBean) VALUES (11,9,0,4,123,456,0,50,'fixture-id',?)", new Object[]{encoded}); + legacy.setVersion(6); legacy.close(); + SagerDatabase db = Room.databaseBuilder(context, SagerDatabase.class, "migration-test").allowMainThreadQueries().build(); + try { + RuleEntity old = db.rulesDao().getById(42); + assertNotNull(old); assertEquals("existing", old.getName()); assertEquals("example.invalid", old.getDomains()); + assertEquals(Collections.singleton("existing.app"), old.getPackages()); assertEquals("", old.getRemoteRuleSetTags()); + assertEquals(7, db.getOpenHelper().getWritableDatabase().getVersion()); + assertEquals("Existing group",db.groupDao().getById(9).getName()); + assertTrue(db.groupDao().getById(9).isSelector()); + assertEquals("Existing node",db.proxyDao().getById(11).getSocksBean().name); + assertEquals(123,db.proxyDao().getById(11).getTx()); assertEquals(456,db.proxyDao().getById(11).getRx()); + RemoteRuleSetEntity first = entity(0, "first"); first.setId(db.remoteRuleSetsDao().insert(first)); + RemoteRuleSetEntity second = entity(0, "second"); second.setId(db.remoteRuleSetsDao().insert(second)); + assertEquals(2, db.remoteRuleSetsDao().all().size()); + first.setEnabled(false); db.remoteRuleSetsDao().update(first); + assertFalse(db.remoteRuleSetsDao().get(first.getId()).getEnabled()); + first.setEnabled(true); db.remoteRuleSetsDao().update(first); + assertTrue(db.remoteRuleSetsDao().get(first.getId()).getEnabled()); + try { db.remoteRuleSetsDao().insert(entity(0, "first")); fail("duplicate tag"); } catch (android.database.sqlite.SQLiteConstraintException expected) { } + old.setRemoteRuleSetTags("first\nsecond"); db.rulesDao().updateRule(old); + assertEquals("first\nsecond", db.rulesDao().getById(42).getRemoteRuleSetTags()); + db.remoteRuleSetsDao().delete(second.getId()); assertNull(db.remoteRuleSetsDao().get(second.getId())); + assertEquals("existing", db.rulesDao().getById(42).getName()); + } finally { db.close(); } + } + + @Test public void oldParcelEncodingIsUnchangedAndOptionalBackupRoundTrips() throws Exception { + RuleEntity original = new RuleEntity(); original.setId(42); original.setName("legacy"); + original.setDomains("example.invalid"); original.setPackages(Collections.singleton("existing.app")); + Parcel before = Parcel.obtain(); original.writeToParcel(before, 0); byte[] old = before.marshall(); before.recycle(); + original.setRemoteRuleSetTags("first\nsecond"); + Parcel after = Parcel.obtain(); original.writeToParcel(after, 0); assertArrayEquals(old, after.marshall()); + after.setDataPosition(0); RuleEntity restored = ParcelizeBridge.createRule(after); after.recycle(); + assertEquals("legacy", restored.getName()); assertEquals("", restored.getRemoteRuleSetTags()); + List sets = Arrays.asList(entity(1,"first"), entity(2,"second")); + assertEquals(sets, store.parse(new JSONArray(store.export(sets).toString()))); + assertTrue(store.parse(null).isEmpty()); + JSONArray invalid = store.export(Arrays.asList(entity(1,"same"), entity(2,"same"))); + try { store.parse(invalid); fail("duplicate backup tag"); } catch (IllegalArgumentException expected) { } + store.validateReferences(sets, Collections.singletonList(original)); + original.setEnabled(true); sets.get(0).setEnabled(false); + try { store.validateReferences(sets, Collections.singletonList(original)); fail("disabled reference"); } catch (IllegalArgumentException expected) { } + original.setRemoteRuleSetTags("missing"); + try { store.validateReferences(sets, Collections.singletonList(original)); fail("missing reference"); } catch (IllegalStateException expected) { } + } + + @Test public void actualBackupV1ImportAndNewBackupRoundTrip() throws Exception { + io.nekohasekai.sagernet.SagerNet application = new io.nekohasekai.sagernet.SagerNet(); + org.robolectric.util.ReflectionHelpers.callInstanceMethod(application,"attach", + org.robolectric.util.ReflectionHelpers.ClassParameter.from(android.content.Context.class,RuntimeEnvironment.getApplication())); + io.nekohasekai.sagernet.SagerNet.Companion.setApplication(application); + androidx.work.WorkManager.initialize(application,new androidx.work.Configuration.Builder().build()); + SagerDatabase db = SagerDatabase.Companion.getInstance(); + io.nekohasekai.sagernet.ui.BackupFragment backup = new io.nekohasekai.sagernet.ui.BackupFragment(); + try { + RuleEntity rule = new RuleEntity(); rule.setId(42); rule.setName("old backup rule"); rule.setDomains("example.invalid"); + db.rulesDao().createRule(rule); + JSONObject old = new JSONObject(backup.doBackup(false,true,false)); + old.remove("remoteRuleSets"); old.remove("remoteRuleSetReferences"); + backup.finishImport(old,false,true,false); + assertEquals("old backup rule",db.rulesDao().getById(42).getName()); + assertTrue(db.remoteRuleSetsDao().all().isEmpty()); + RemoteRuleSetEntity e = entity(1,"first"); e.setEnabled(false); + db.remoteRuleSetsDao().insert(e); + rule.setRemoteRuleSetTags("first"); db.rulesDao().updateRule(rule); + JSONObject exported = new JSONObject(backup.doBackup(false,true,false)); + assertEquals(1,exported.getInt("version")); + backup.finishImport(exported,false,true,false); + assertEquals(e,db.remoteRuleSetsDao().get(1)); + assertEquals("first",db.rulesDao().getById(42).getRemoteRuleSetTags()); + assertEquals(old.getJSONArray("rules").getString(0),exported.getJSONArray("rules").getString(0)); + JSONObject bad = new JSONObject(exported.toString()); + bad.getJSONArray("remoteRuleSets").getJSONObject(0).put("format","invalid"); + try { backup.finishImport(bad,false,true,false); fail("invalid block"); } catch (IllegalArgumentException expected) { } + assertEquals(e,db.remoteRuleSetsDao().get(1)); assertEquals("first",db.rulesDao().getById(42).getRemoteRuleSetTags()); + JSONObject malformed = new JSONObject(exported.toString()); + malformed.getJSONArray("remoteRuleSets").getJSONObject(0).put("id",new JSONObject().put("url","https://example.invalid/?token=FAKE_TEST_TOKEN")); + try { backup.finishImport(malformed,false,true,false); fail("malformed ID"); } + catch (IllegalArgumentException expected) { + assertFalse(expected.toString().contains("FAKE_TEST_TOKEN")); assertNull(expected.getCause()); + } + try { RemoteRuleSetManager.INSTANCE.delete(1); fail("referenced delete"); } catch (IllegalArgumentException expected) { } + try { RemoteRuleSetManager.INSTANCE.save(new RemoteRuleSetEntity(1,"Fixture","renamed",e.getUrl(),"source",1440,false)); fail("referenced rename"); } catch (IllegalArgumentException expected) { } + rule.setRemoteRuleSetTags(""); db.rulesDao().updateRule(rule); + File cache = new File(application.getFilesDir(),"remote-rule-sets/1.source"); cache.getParentFile().mkdirs(); Files.write(cache.toPath(),"valid".getBytes()); + assertTrue(RemoteRuleSetManager.INSTANCE.config(Collections.emptyList()).isEmpty()); + e.setEnabled(true); db.remoteRuleSetsDao().update(e); + assertTrue(cache.delete()); + assertTrue(RemoteRuleSetManager.INSTANCE.config(Collections.emptyList()).isEmpty()); + rule.setEnabled(true); rule.setRemoteRuleSetTags("first"); + try { RemoteRuleSetManager.INSTANCE.config(Collections.singletonList(rule)); fail("referenced missing cache"); } + catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("no valid cache")); } + Files.write(cache.toPath(),"valid".getBytes()); + List options = RemoteRuleSetManager.INSTANCE.config(Collections.singletonList(rule)); + assertEquals(1,options.size()); assertEquals("local",options.get(0).type); + assertEquals("first",options.get(0).tag); assertEquals("source",options.get(0).format); + assertEquals(cache.getAbsolutePath(),options.get(0).path); assertNull(options.get(0).url); + db.rulesDao().updateRule(rule); + try { RemoteRuleSetManager.INSTANCE.save(e.copy(1,"Fixture","first",e.getUrl(),"source",1440,false)); fail("disable referenced set"); } + catch (IllegalArgumentException expected) { } + assertTrue(db.remoteRuleSetsDao().get(1).getEnabled()); + rule.setRemoteRuleSetTags(""); db.rulesDao().updateRule(rule); + rule.setEnabled(true); rule.setRemoteRuleSetTags("first"); + e.setEnabled(false); db.remoteRuleSetsDao().update(e); + try { RemoteRuleSetManager.INSTANCE.config(Collections.singletonList(rule)); fail("disabled reference config"); } catch (IllegalArgumentException expected) { } + rule.setRemoteRuleSetTags("missing"); + try { RemoteRuleSetManager.INSTANCE.config(Collections.singletonList(rule)); fail("missing reference config"); } catch (IllegalStateException expected) { } + RemoteRuleSetManager.INSTANCE.delete(1); + assertNull(db.remoteRuleSetsDao().get(1)); assertFalse(cache.exists()); + } finally { db.close(); } + } + + @Test public void unusedSubscriptionsDoNotRequireCachesOrAffectActiveRoutes() { + RemoteRuleSetEntity unused = entity(2,"unused"); + RuleEntity disabled = new RuleEntity(); disabled.setEnabled(false); disabled.setRemoteRuleSetTags("missing"); + assertTrue(store.required(Collections.singletonList(unused),Collections.singletonList(disabled)).isEmpty()); + RuleEntity active = new RuleEntity(); active.setEnabled(true); active.setRemoteRuleSetTags("used"); + RemoteRuleSetEntity used = entity(1,"used"); + assertEquals(Collections.singletonList(used),store.required(Arrays.asList(used,unused),Arrays.asList(active,disabled))); + used.setEnabled(false); + try { store.required(Arrays.asList(used,unused),Collections.singletonList(active)); fail("disabled reference"); } + catch (IllegalArgumentException expected) { } + } + + @Test public void failedImportRollsBackProfilesRoutesSettingsAndPreservesCache() throws Exception { + io.nekohasekai.sagernet.SagerNet application = new io.nekohasekai.sagernet.SagerNet(); + org.robolectric.util.ReflectionHelpers.callInstanceMethod(application,"attach", + org.robolectric.util.ReflectionHelpers.ClassParameter.from(android.content.Context.class,RuntimeEnvironment.getApplication())); + io.nekohasekai.sagernet.SagerNet.Companion.setApplication(application); + androidx.work.WorkManager.initialize(application,new androidx.work.Configuration.Builder().build()); + SagerDatabase db = SagerDatabase.Companion.getInstance(); + io.nekohasekai.sagernet.database.preference.PublicDatabase publicDb = io.nekohasekai.sagernet.database.preference.PublicDatabase.Companion.getInstance(); + try { + RuleEntity old = new RuleEntity(); old.setId(42); old.setName("before"); + db.rulesDao().createRule(old); + ProxyGroup group = new ProxyGroup(); group.setId(7); group.setName("original group"); + db.groupDao().insert(Collections.singletonList(group)); + ProxyEntity node = new ProxyEntity(); node.setId(11); node.setGroupId(7); + io.nekohasekai.sagernet.fmt.socks.SOCKSBean bean = new io.nekohasekai.sagernet.fmt.socks.SOCKSBean(); + bean.initializeDefaultValues(); bean.name="original node"; bean.serverAddress="example.invalid"; bean.serverPort=1080; + node.setSocksBean(bean); db.proxyDao().insert(Collections.singletonList(node)); + RemoteRuleSetEntity set = entity(1,"first"); set.setEnabled(false); db.remoteRuleSetsDao().insert(set); + File cache = new File(application.getFilesDir(),"remote-rule-sets/1.source"); cache.getParentFile().mkdirs(); + Files.write(cache.toPath(),"previous cache".getBytes()); + io.nekohasekai.sagernet.database.preference.KeyValuePair value = new io.nekohasekai.sagernet.database.preference.KeyValuePair("fixture"); + value.put("before"); publicDb.keyValuePairDao().put(value); + io.nekohasekai.sagernet.ui.BackupFragment backup = new io.nekohasekai.sagernet.ui.BackupFragment(); + old.setName("after"); db.rulesDao().updateRule(old); + JSONObject incoming = new JSONObject(backup.doBackup(true,true,true)); + incoming.put("profiles",new JSONArray()); + old.setName("before"); db.rulesDao().updateRule(old); + JSONArray settings = incoming.getJSONArray("settings"); + settings.put(settings.get(0)); // Valid parcels; duplicate key fails after all other writes. + try { backup.finishImport(incoming,true,true,true); fail("duplicate setting"); } + catch (android.database.sqlite.SQLiteConstraintException expected) { } + assertEquals("before",db.rulesDao().getById(42).getName()); + assertEquals("original node",db.proxyDao().getById(11).getSocksBean().name); + assertEquals("original group",db.groupDao().getById(7).getName()); + assertEquals(set,db.remoteRuleSetsDao().get(1)); + assertEquals("before",publicDb.keyValuePairDao().get("fixture").getString()); + assertEquals("previous cache",readText(cache.toPath())); + incoming.getJSONArray("settings").remove(settings.length()-1); + incoming.put("groups",new JSONArray().put("not a parcel")); + try { backup.finishImport(incoming,true,true,true); fail("invalid groups"); } + catch (Exception expected) { } + assertEquals("before",db.rulesDao().getById(42).getName()); + assertEquals("previous cache",readText(cache.toPath())); + incoming.put("groups",new JSONArray()); + backup.finishImport(incoming,true,true,true); + assertEquals("after",db.rulesDao().getById(42).getName()); + } finally { db.close(); publicDb.close(); } + } + + @Test public void redirectsAreRejectedBeforeContactingTheTarget() throws Exception { + java.net.ServerSocket target = new java.net.ServerSocket(0,10,java.net.InetAddress.getByName("127.0.0.1")); + target.setSoTimeout(300); + java.net.ServerSocket source = new java.net.ServerSocket(0,10,java.net.InetAddress.getByName("127.0.0.1")); + AtomicReference serverFailure = new AtomicReference<>(); + Thread reply = new Thread(() -> { + try (java.net.Socket socket = source.accept()) { + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + while (!reader.readLine().isEmpty()) { } + socket.getOutputStream().write(("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:"+target.getLocalPort()+"/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes()); + socket.getOutputStream().flush(); + } catch (Throwable e) { serverFailure.set(e); } + }); + reply.start(); + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder().proxy(java.net.Proxy.NO_PROXY).callTimeout(3,TimeUnit.SECONDS).build(); + File cache = Files.createTempDirectory("redirect-cache").resolve("1.source").toFile(); + Files.write(cache.toPath(),"old".getBytes()); + try { + try { RemoteRuleSetDownload.INSTANCE.fetch(client,"http://127.0.0.1:"+source.getLocalPort()+"/rules",cache, + file -> { throw new AssertionError("redirect must not be validated"); }, + (from,to) -> { throw new AssertionError("redirect must not replace cache"); }); fail("redirect"); } + catch (Exception expected) { assertTrue(expected instanceof RemoteRuleSetManager.DownloadFailure); assertEquals("HTTP 302",expected.getMessage()); } + try (java.net.Socket ignored = target.accept()) { fail("redirect target contacted"); } + catch (java.net.SocketTimeoutException expected) { } + reply.join(3000); assertFalse(reply.isAlive()); assertNull(serverFailure.get()); + assertEquals("old",readText(cache.toPath())); + } finally { + source.close(); target.close(); reply.join(3000); + client.connectionPool().evictAll(); client.dispatcher().executorService().shutdownNow(); + } + } + + @Test public void atomicReplacementKeepsPreviousCacheOnEveryFailure() throws Exception { + File directory = Files.createTempDirectory("rule-set-test").toFile(); + File target = new File(directory,"1.source"); Files.write(target.toPath(),"old".getBytes()); + kotlin.jvm.functions.Function2 rename = (from,to) -> { + try { Files.move(from.toPath(),to.toPath(),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING); return kotlin.Unit.INSTANCE; } + catch (IOException e) { throw new RuntimeException(e); } + }; + try { + store.replace(target,new ByteArrayInputStream("bad".getBytes()), file -> { throw new IllegalArgumentException("invalid"); },rename); + fail("invalid content"); + } catch (IllegalArgumentException expected) { } + assertEquals("old", readText(target.toPath())); + try { + store.replace(target,new InputStream() { public int read() throws IOException { throw new IOException("network"); } }, file -> kotlin.Unit.INSTANCE,rename); + fail("network failure"); + } catch (Exception expected) { assertTrue(expected instanceof IOException); } + assertEquals("old", readText(target.toPath())); + try { + store.replace(target,new ByteArrayInputStream("new".getBytes()), file -> kotlin.Unit.INSTANCE,(a,b) -> { throw new IllegalStateException("rename failed"); }); + fail("rename failure"); + } catch (IllegalStateException expected) { } + assertEquals("old", readText(target.toPath())); + AtomicBoolean stop = new AtomicBoolean(); AtomicReference failure = new AtomicReference<>(); + Thread reader = new Thread(() -> { while (!stop.get()) try { + String value = readText(target.toPath()); + if (!value.equals("old") && !value.equals("new")) throw new AssertionError(value); + } catch (Throwable error) { failure.set(error); break; } }); reader.start(); + int completed = 0, sharingDenials = 0; + try { + for (int i=0;i<100;i++) try { + store.replace(target,new ByteArrayInputStream((i%2==0?"new":"old").getBytes()),file -> kotlin.Unit.INSTANCE,rename); + completed++; + } catch (RuntimeException e) { + // Windows may reject replacing an open file. The production Android implementation + // uses POSIX rename; verify refusal is safe here, without adding retries to production. + if (!System.getProperty("os.name").startsWith("Windows") || !(e.getCause() instanceof AccessDeniedException)) throw e; + sharingDenials++; + } + } finally { stop.set(true); reader.join(3000); } + assertFalse(reader.isAlive()); assertNull(failure.get()); + System.out.println("Concurrent replacements="+completed+", Windows sharing refusals="+sharingDenials); + store.replace(target,new ByteArrayInputStream("new".getBytes()),file -> kotlin.Unit.INSTANCE,rename); + assertEquals("new",readText(target.toPath())); + assertEquals(1, Objects.requireNonNull(directory.list()).length); + } + + @Test public void realHttpSuccessFailureAndInvalidContentRetainCache() throws Exception { + File directory = Files.createTempDirectory("rule-set-http").toFile(); + File target = new File(directory,"1.source"); Files.write(target.toPath(),"old".getBytes()); + java.net.ServerSocket server = new java.net.ServerSocket(0, 10, java.net.InetAddress.getByName("127.0.0.1")); + AtomicReference serverFailure = new AtomicReference<>(); + Thread replies = new Thread(() -> { + try { for (int i=0;i<3;i++) try (java.net.Socket socket = server.accept()) { + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + while (!reader.readLine().isEmpty()) { } + String body = i == 0 ? "valid" : "invalid"; + String status = i == 1 ? "503 Unavailable" : "200 OK"; + socket.getOutputStream().write(("HTTP/1.1 "+status+"\r\nContent-Length: "+body.length()+"\r\nConnection: close\r\n\r\n"+body).getBytes()); + socket.getOutputStream().flush(); socket.shutdownOutput(); socket.setSoTimeout(3000); + while (reader.read() != -1) { } + } } catch (IOException e) { if (!server.isClosed()) serverFailure.set(e); } + }); replies.start(); + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder().proxy(java.net.Proxy.NO_PROXY).callTimeout(3, TimeUnit.SECONDS).build(); + String url = "http://127.0.0.1:"+server.getLocalPort()+"/fixture"; + kotlin.jvm.functions.Function1 validate = file -> { + try { if (!readText(file.toPath()).equals("valid")) throw new IllegalArgumentException("invalid"); } + catch (IOException e) { throw new RuntimeException(e); } + return kotlin.Unit.INSTANCE; + }; + kotlin.jvm.functions.Function2 rename = (from,to) -> { + try { Files.move(from.toPath(),to.toPath(),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING); return kotlin.Unit.INSTANCE; } + catch (IOException e) { throw new RuntimeException(e); } + }; + try { + RemoteRuleSetDownload.INSTANCE.fetch(client,url,target,validate,rename); + assertEquals("valid",readText(target.toPath())); + try { RemoteRuleSetDownload.INSTANCE.fetch(client,url,target,validate,rename); fail("HTTP failure"); } + catch (Exception e) { assertEquals("HTTP 503",e.getMessage()); } + try { RemoteRuleSetDownload.INSTANCE.fetch(client,url,target,validate,rename); fail("invalid content"); } + catch (IllegalArgumentException expected) { } + assertEquals("valid",readText(target.toPath())); + server.close(); replies.join(3000); assertFalse(replies.isAlive()); + assertNull(serverFailure.get()); + try { RemoteRuleSetDownload.INSTANCE.fetch(client,url,target,validate,rename); fail("network failure"); } + catch (Exception expected) { assertTrue(expected instanceof IOException); } + assertEquals("valid",readText(target.toPath())); + assertEquals(1,Objects.requireNonNull(directory.list()).length); + } finally { server.close(); client.connectionPool().evictAll(); client.dispatcher().executorService().shutdownNow(); replies.join(3000); } + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/group/RawUpdaterYamlSecurityTest.java b/app/src/test/java/io/nekohasekai/sagernet/group/RawUpdaterYamlSecurityTest.java new file mode 100644 index 0000000000..e7c666e874 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/group/RawUpdaterYamlSecurityTest.java @@ -0,0 +1,38 @@ +package io.nekohasekai.sagernet.group; + +import org.junit.Test; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class RawUpdaterYamlSecurityTest { + private Yaml safeYaml() { + Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions())); + yaml.addTypeDescription(new TypeDescription(String.class, "str")); + return yaml; + } + + @Test public void acceptsOrdinaryClashSubscriptionYaml() { + Map parsed = safeYaml().loadAs( + "proxies:\n - name: fixture\n type: socks5\n server: example.com\n port: 1080\n", + Map.class); + List proxies = (List) parsed.get("proxies"); + assertEquals("fixture", ((Map) proxies.get(0)).get("name")); + } + + @Test public void rejectsArbitraryJavaTypeTags() { + try { + safeYaml().loadAs("proxies:\n - !!java.io.File [/tmp/nekobox-yaml-marker]\n", Map.class); + fail("untrusted Java type tags must be rejected"); + } catch (YAMLException expected) { + // SafeConstructor rejects global Java class tags. + } + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/widget/ServiceButtonLayoutTest.java b/app/src/test/java/io/nekohasekai/sagernet/widget/ServiceButtonLayoutTest.java new file mode 100644 index 0000000000..4e8ffe0d9d --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/widget/ServiceButtonLayoutTest.java @@ -0,0 +1,84 @@ +package io.nekohasekai.sagernet.widget; + +import android.app.Application; +import android.content.Context; +import android.os.Looper; +import android.graphics.RectF; +import android.view.*; +import android.widget.TextView; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleRegistry; +import io.nekohasekai.sagernet.R; +import io.nekohasekai.sagernet.SagerNet; +import io.nekohasekai.sagernet.bg.BaseService; +import io.nekohasekai.sagernet.ui.MainActivity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.*; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; +import java.time.Duration; +import java.util.ArrayList; +import static org.junit.Assert.*; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk={21,28,35}, application=Application.class, manifest=Config.NONE, + instrumentedPackages="io.nekohasekai.sagernet.ktx", shadows=StatsBarContextTest.ShadowUtils.class) +public class ServiceButtonLayoutTest { + private RectF rect(View view) { + float x=view.getX(), y=view.getY(); + ViewParent p=view.getParent(); + while(p instanceof View){x+=((View)p).getX();y+=((View)p).getY();p=p.getParent();} + return new RectF(x,y,x+view.getWidth(),y+view.getHeight()); + } + @Test public void stateTransitionsKeepServiceControlOutsideStatsContent() { + SagerNet app=new SagerNet(); + ReflectionHelpers.callInstanceMethod(app,"attach",ReflectionHelpers.ClassParameter.from(Context.class,RuntimeEnvironment.getApplication())); + SagerNet.Companion.setApplication(app); + ArrayList failures=new ArrayList<>(); + for(boolean night:new boolean[]{false,true}) for(boolean landscape:new boolean[]{false,true}) { + RuntimeEnvironment.setQualifiers((landscape?"land":"port")+"-"+(night?"night":"notnight")); + MainActivity activity=Robolectric.buildActivity(MainActivity.class).get(); + activity.setTheme(R.style.Theme_SagerNet_Pink_SSR); + androidx.savedstate.SavedStateRegistryController saved=ReflectionHelpers.getField(activity,"savedStateRegistryController"); + saved.performRestore(null); + LifecycleRegistry lifecycle=(LifecycleRegistry)activity.getLifecycle(); + lifecycle.setCurrentState(Lifecycle.State.STARTED); + ViewGroup root=(ViewGroup)LayoutInflater.from(activity).inflate(R.layout.layout_main,null,false); + root.removeView(root.findViewById(R.id.nav_view_black)); + ServiceButton fab=root.findViewById(R.id.fab); + StatsBar bar=root.findViewById(R.id.stats); + fab.initProgress(root.findViewById(R.id.fabProgress)); + bar.setOnClickListener(v->{}); + int[] clicks={0};fab.setOnClickListener(v->clicks[0]++); + int w=landscape?934:425,h=landscape?425:934; + BaseService.State previous=BaseService.State.Stopped; + for(BaseService.State state:new BaseService.State[]{BaseService.State.Idle,BaseService.State.Stopped,BaseService.State.Connecting,BaseService.State.Connected,BaseService.State.Stopping,BaseService.State.Stopped}) { + fab.changeState(state,previous,false);bar.changeState(state); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500)); + bar.updateSpeed(1024,2048); + root.measure(View.MeasureSpec.makeMeasureSpec(w,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(h,View.MeasureSpec.EXACTLY));root.layout(0,0,w,h); + RectF fr=rect(fab),br=rect(bar),content=rect(bar.getChildAt(0)); + System.out.println("STATE="+state+" night="+night+" landscape="+landscape+" fab="+fr+" bar="+br+" content="+content+" fabZ="+fab.getZ()+" barZ="+bar.getZ()+" anchorMode="+bar.getFabAnchorMode()); + assertEquals(View.VISIBLE,fab.getVisibility());assertEquals(1f,fab.getAlpha(),0.001f); + assertTrue(fab.isClickable()); + assertEquals(state.getCanStop() || state==BaseService.State.Stopped,fab.isEnabled()); + if(fab.isEnabled()){int before=clicks[0];fab.performClick();assertEquals(before+1,clicks[0]);} + assertEquals(R.id.stats,fab.getNextFocusDownId());assertEquals(R.id.fab,bar.getNextFocusUpId()); + assertTrue(((TextView)root.findViewById(R.id.tx)).getText().length()>0); + assertTrue(((TextView)root.findViewById(R.id.rx)).getText().length()>0); + for(int id:new int[]{R.id.tx,R.id.rx}){ + TextView text=root.findViewById(id); + assertEquals(0,text.getLayout().getEllipsisCount(0)); + } + assertEquals(R.id.fab,((androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams)root.findViewById(R.id.fabProgress).getLayoutParams()).getAnchorId()); + // A cradle may intersect the bar edge; its center must remain above the full-width content. + if(state==BaseService.State.Connected && fr.centerY()>br.top) failures.add("FAB center embedded in statistics content: "+fr+" / "+content); + previous=state; + } + lifecycle.setCurrentState(Lifecycle.State.DESTROYED); + } + assertTrue(failures.toString(),failures.isEmpty()); + } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarConnectionTest.java b/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarConnectionTest.java new file mode 100644 index 0000000000..706f73d697 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarConnectionTest.java @@ -0,0 +1,128 @@ +package io.nekohasekai.sagernet.widget; + +import android.app.Application; +import android.content.Context; +import android.os.Looper; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.appcompat.view.ContextThemeWrapper; +import com.google.android.material.snackbar.Snackbar; +import io.nekohasekai.sagernet.R; +import io.nekohasekai.sagernet.SagerNet; +import io.nekohasekai.sagernet.ui.MainActivity; +import io.nekohasekai.sagernet.ui.ThemedActivity; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.*; +import org.robolectric.util.ReflectionHelpers; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import static org.junit.Assert.*; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = Application.class, manifest = Config.NONE, + instrumentedPackages = {"io.nekohasekai.sagernet.ktx", "io.nekohasekai.sagernet.ui", "io.nekohasekai.sagernet.database"}, + shadows = {StatsBarContextTest.ShadowUtils.class, StatsBarConnectionTest.ActivityBoundary.class, + StatsBarConnectionTest.SnackbarBoundary.class, StatsBarConnectionTest.LogBoundary.class, + StatsBarConnectionTest.SettingsBoundary.class}) +public class StatsBarConnectionTest { + private static CountDownLatch entered, release; + private static boolean fail; + private static String warning, feedback; + private static FrameLayout root; + private StatsBar bar; + private TextView status; + + @Implements(value = MainActivity.class, isInAndroidSdk = false) + public static class ActivityBoundary extends SnackbarBoundary { + @Implementation protected int urlTest() throws Exception { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("Test never released URLTest"); + if (fail) throw new IOException("controlled URLTest failure"); + return 123; + } + } + @Implements(value = ThemedActivity.class, isInAndroidSdk = false) + public static class SnackbarBoundary extends org.robolectric.shadows.ShadowActivity { + @Implementation protected Snackbar snackbar(CharSequence text) { + feedback = text.toString(); + return Snackbar.make(root, text, Snackbar.LENGTH_LONG); + } + } + @Implements(value = io.nekohasekai.sagernet.ktx.Logs.class, isInAndroidSdk = false) + public static class LogBoundary { + @Implementation protected void w(String text) { warning = text; } + } + @Implements(value = io.nekohasekai.sagernet.database.DataStore.class, isInAndroidSdk = false) + public static class SettingsBoundary { + @Implementation protected static void __staticInitializer__() {} + @Implementation protected String getConnectionTestURL() { return "https://example.com/"; } + } + + @Before public void setup() { + warning = feedback = null; + SagerNet app = new SagerNet(); + ReflectionHelpers.callInstanceMethod(app, "attach", ReflectionHelpers.ClassParameter.from( + Context.class, RuntimeEnvironment.getApplication())); + SagerNet.Companion.setApplication(app); + ReflectionHelpers.setStaticField(io.nekohasekai.sagernet.database.DataStore.class, "INSTANCE", + ReflectionHelpers.callConstructor(io.nekohasekai.sagernet.database.DataStore.class)); + MainActivity activity = Robolectric.buildActivity(MainActivity.class).get(); + activity.setTheme(R.style.Theme_SagerNet_Pink_SSR); + root = new FrameLayout(activity); + root.setId(android.R.id.content); + bar = new StatsBar(new ContextThemeWrapper(activity, R.style.Theme_SagerNet_Pink_SSR)); + root.addView(bar); + for (int id : new int[]{R.id.status, R.id.tx, R.id.rx}) { + TextView text = new TextView(bar.getContext()); + text.setId(id); + bar.addView(text); + } + bar.setOnClickListener(v -> bar.testConnection()); + status = bar.findViewById(R.id.status); + bar.updateSpeed(1024, 2048); + } + + private void query(boolean failure) throws Exception { + fail = failure; + entered = new CountDownLatch(1); + release = new CountDownLatch(1); + String tx = ((TextView) bar.findViewById(R.id.tx)).getText().toString(); + String rx = ((TextView) bar.findViewById(R.id.rx)).getText().toString(); + assertTrue(bar.isEnabled()); + assertTrue(bar.performClick()); + assertFalse(bar.isEnabled()); + assertEquals(bar.getContext().getString(R.string.connection_test_testing), status.getText().toString()); + try { assertTrue("Production URLTest boundary was reached", entered.await(5, TimeUnit.SECONDS)); } + finally { release.countDown(); } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!bar.isEnabled() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle(); + Thread.sleep(5); + } + assertTrue("Production completion re-enables StatsBar", bar.isEnabled()); + assertEquals(tx, ((TextView) bar.findViewById(R.id.tx)).getText().toString()); + assertEquals(rx, ((TextView) bar.findViewById(R.id.rx)).getText().toString()); + if (failure) { + assertEquals("java.io.IOException: controlled URLTest failure", warning); + assertEquals(bar.getContext().getString(R.string.connection_test_error, + "controlled URLTest failure"), feedback); + assertNotEquals("A finished failure must not retain testing", + bar.getContext().getString(R.string.connection_test_testing), status.getText().toString()); + assertEquals(bar.getContext().getString(R.string.connection_test_failed), status.getText().toString()); + } else { + assertEquals(bar.getContext().getString(R.string.connection_test_available, 123), status.getText().toString()); + } + } + + @Test public void successDisplaysLatency() throws Exception { query(false); } + @Test public void failureCompletesWithErrorFeedback() throws Exception { query(true); } + @Test public void failureCanRetrySuccessfully() throws Exception { query(true); query(false); } + @Test public void consecutiveFailuresRemainRetryable() throws Exception { query(true); query(true); } +} diff --git a/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarContextTest.java b/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarContextTest.java new file mode 100644 index 0000000000..303ad35581 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/widget/StatsBarContextTest.java @@ -0,0 +1,86 @@ +package io.nekohasekai.sagernet.widget; + +import android.app.Application; +import android.content.Context; +import android.os.Looper; +import android.widget.TextView; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleRegistry; +import io.nekohasekai.sagernet.R; +import io.nekohasekai.sagernet.SagerNet; +import io.nekohasekai.sagernet.bg.BaseService; +import io.nekohasekai.sagernet.ui.MainActivity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Implementation; +import org.robolectric.util.ReflectionHelpers; +import java.time.Duration; +import static org.junit.Assert.*; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {21, 28, 35}, application = Application.class, manifest = Config.NONE, + instrumentedPackages = "io.nekohasekai.sagernet.ktx", + shadows = StatsBarContextTest.ShadowUtils.class) +public class StatsBarContextTest { + @Implements(value = io.nekohasekai.sagernet.ktx.UtilsKt.class, isInAndroidSdk = false) + public static class ShadowUtils { + // The JVM lacks Android's Socket.getFileDescriptor$/FileDescriptor.getInt$. + // Suppress only that unrelated static initialization; getApp and callbacks remain real. + @Implementation protected static void __staticInitializer__() {} + } + @Test public void wrappedContextPreservesStateAndLifecycleCallbacks() { + // Keep native Core/service startup out of this widget contract; real startup is device-tested. + SagerNet app = new SagerNet(); + ReflectionHelpers.callInstanceMethod(app, "attach", + ReflectionHelpers.ClassParameter.from(Context.class, RuntimeEnvironment.getApplication())); + SagerNet.Companion.setApplication(app); + MainActivity activity = Robolectric.buildActivity(MainActivity.class).get(); + activity.setTheme(R.style.Theme_SagerNet_Pink_SSR); + androidx.savedstate.SavedStateRegistryController savedState = + ReflectionHelpers.getField(activity, "savedStateRegistryController"); + savedState.performRestore(null); + LifecycleRegistry lifecycle = (LifecycleRegistry) activity.getLifecycle(); + lifecycle.setCurrentState(Lifecycle.State.CREATED); + Context wrapped = new ContextThemeWrapper( + new ContextThemeWrapper(activity, R.style.Theme_SagerNet_Pink_SSR), + R.style.Theme_SagerNet_Pink_SSR); + StatsBar bar = new StatsBar(wrapped); + assertFalse(bar.getContext() instanceof MainActivity); + for (int id : new int[]{R.id.status, R.id.tx, R.id.rx}) { + TextView text = new TextView(bar.getContext()); + text.setId(id); + bar.addView(text); + } + bar.setOnClickListener(v -> {}); + TextView status = bar.findViewById(R.id.status); + for (BaseService.State state : new BaseService.State[]{BaseService.State.Idle, + BaseService.State.Connecting, BaseService.State.Stopping}) { + bar.changeState(state); + int expected = state == BaseService.State.Connecting ? R.string.connecting : + state == BaseService.State.Stopping ? R.string.stopping : R.string.not_connected; + assertEquals(bar.getContext().getText(expected).toString(), status.getText().toString()); + assertFalse(bar.getHideOnScroll()); + assertTrue(((TextView) bar.findViewById(R.id.tx)).getText().toString().startsWith("▲")); + } + bar.changeState(BaseService.State.Connected); + assertTrue(bar.getHideOnScroll()); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(150)); + assertEquals(bar.getContext().getText(R.string.stopping).toString(), status.getText().toString()); + lifecycle.setCurrentState(Lifecycle.State.STARTED); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(150)); + assertEquals(app.getText(R.string.vpn_connected).toString(), status.getText().toString()); + lifecycle.setCurrentState(Lifecycle.State.CREATED); + status.setText("unchanged after destroy"); + bar.changeState(BaseService.State.Connected); + lifecycle.setCurrentState(Lifecycle.State.DESTROYED); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(150)); + assertEquals("unchanged after destroy", status.getText().toString()); + } +} diff --git a/app/src/test/phase2.init.gradle b/app/src/test/phase2.init.gradle new file mode 100644 index 0000000000..760b59101d --- /dev/null +++ b/app/src/test/phase2.init.gradle @@ -0,0 +1,15 @@ +allprojects { p -> + p.afterEvaluate { + if (p.path == ':app') { + p.dependencies.add('testImplementation', 'junit:junit:4.13.2') + p.dependencies.add('testImplementation', 'org.robolectric:robolectric:4.14.1') + p.android.testOptions.unitTests.includeAndroidResources = true + p.tasks.withType(Test).configureEach { + maxHeapSize = '1536m' + systemProperty 'phase2.schemaDir', new File(p.projectDir, 'schemas/io.nekohasekai.sagernet.database.SagerDatabase').absolutePath + systemProperty 'robolectric.dependency.repo.url', 'https://repo.maven.apache.org/maven2' + testLogging { events 'passed', 'failed', 'skipped'; exceptionFormat 'full' } + } + } + } +} diff --git a/buildScript/lib/core/get_source.sh b/buildScript/lib/core/get_source.sh index 1c1a18d758..d00c458f04 100755 --- a/buildScript/lib/core/get_source.sh +++ b/buildScript/lib/core/get_source.sh @@ -1,29 +1,33 @@ #!/bin/bash -set -e - -source "buildScript/init/env.sh" -ENV_NB4A=1 +set -euo pipefail source "buildScript/lib/core/get_source_env.sh" -pushd .. - -#### - -if [ ! -d "sing-box" ]; then - git clone --no-checkout https://github.com/MatsuriDayo/sing-box.git -fi -pushd sing-box -git checkout "$COMMIT_SING_BOX" -popd - -#### - -if [ ! -d "libneko" ]; then - git clone --no-checkout https://github.com/MatsuriDayo/libneko.git -fi -pushd libneko -git checkout "$COMMIT_LIBNEKO" -popd -#### +checkout_source() { + local directory="$1" remote="$2" commit="$3" tag="${4:-}" + if [ ! -e "$directory" ]; then + git clone --no-checkout "$remote" "$directory" + git -C "$directory" cat-file -e "$commit^{commit}" 2>/dev/null || git -C "$directory" fetch origin "$commit" + git -C "$directory" checkout --detach "$commit" + else + [ "$(git -C "$directory" remote get-url origin)" = "$remote" ] || { + echo "Refusing to reuse a checkout from a different origin: $directory" >&2; return 1; + } + [ -z "$(git -C "$directory" status --porcelain --untracked-files=all)" ] || { + echo "Source checkout is not clean: $directory" >&2; return 1; + } + [ "$(git -C "$directory" rev-parse HEAD)" = "$commit" ] || { + echo "Existing checkout is not at the approved commit; use an isolated sibling: $directory" >&2; return 1; + } + fi + if [ -n "$tag" ]; then + if ! git -C "$directory" rev-parse --verify "refs/tags/$tag^{commit}" >/dev/null 2>&1; then + git -C "$directory" fetch origin "refs/tags/$tag:refs/tags/$tag" + fi + [ "$(git -C "$directory" rev-parse "refs/tags/$tag^{commit}")" = "$commit" ] + fi + [ "$(git -C "$directory" rev-parse HEAD)" = "$commit" ] + [ -z "$(git -C "$directory" status --porcelain --untracked-files=all)" ] +} -popd +checkout_source ../sing-box https://github.com/SagerNet/sing-box.git "$COMMIT_SING_BOX" "v$VERSION_SING_BOX" +checkout_source ../libneko https://github.com/MatsuriDayo/libneko.git "$COMMIT_LIBNEKO" diff --git a/buildScript/lib/core/get_source_env.sh b/buildScript/lib/core/get_source_env.sh index 2a369106fa..38d09e3326 100644 --- a/buildScript/lib/core/get_source_env.sh +++ b/buildScript/lib/core/get_source_env.sh @@ -1,2 +1,5 @@ -export COMMIT_SING_BOX="aed32ee3066cdbc7d471e3e0415c5134088962df" +export COMMIT_SING_BOX="0b8995879f29a9b98ee027bc17b75e101445b238" +export VERSION_SING_BOX="1.14.0" +export COMMIT_GOMOBILE="b2c30f47825831593d6980af8191527490f9c968" +export VERSION_GOMOBILE="v0.1.12" export COMMIT_LIBNEKO="1c47a3af71990a7b2192e03292b4d246c308ef0b" diff --git a/buildScript/test/core.sh b/buildScript/test/core.sh new file mode 100755 index 0000000000..46f1c12443 --- /dev/null +++ b/buildScript/test/core.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +source "$repo/buildScript/lib/core/get_source_env.sh" +export GOTOOLCHAIN=go1.25.5 +test "$(git -C "$repo/../sing-box" rev-parse HEAD)" = "$COMMIT_SING_BOX" +test -z "$(git -C "$repo/../sing-box" status --porcelain --untracked-files=all)" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/app/libcore" +ln -s "$repo/../sing-box" "$work/sing-box" +ln -s "$repo/../libneko" "$work/libneko" +files=(box.go box_include.go certs.go cert_options.go dns_box.go geo_rules.go geoip.go geosite.go + platform_java.go sniff_compat.go compat_host_test.go dns_callback_test.go) +for file in "${files[@]}" go.mod go.sum; do + cp "$repo/libcore/$file" "$work/app/libcore/" +done +cp -R "$repo/libcore/device" "$work/app/libcore/device" +if [ -n "${NEKO_GEO_ASSETS:-}" ]; then + cp "$repo/app/src/test/core/remote_rule_set_test.go" "$work/app/libcore/" + cp "$repo/app/src/test/core/replace_unix_test.go" "$work/app/libcore/" + files+=(remote_rule_set_test.go replace_unix_test.go) +fi +if [ -n "${NEKO_LEGACY_GEO_FIXTURES:-}" ]; then + cp "$repo/app/src/test/core/legacy_geo_rule_test.go" "$work/app/libcore/" + files+=(legacy_geo_rule_test.go) +fi +cd "$work/app/libcore" +go test -mod=readonly -count=1 -v \ + -tags=with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api "${files[@]}" +# The pinned upstream NetworkManager races during host interface discovery. +# Keep race detection on our callback lifecycle and file-ownership regressions; +# full Core startup remains covered by the functional run above. +go test -mod=readonly -race -count=1 -v -run 'TestDNS|TestGeoSiteLoadsCloseTheirFiles' \ + -tags=with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api "${files[@]}" diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 3561cdd1e1..6bf550d7a4 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -123,7 +123,7 @@ fun Project.setupAppCommon() { if (keystorePwd != null) { signingConfigs { create("release") { - storeFile = rootProject.file("release.keystore") + storeFile = rootProject.file(lp.getProperty("KEYSTORE_PATH") ?: System.getenv("KEYSTORE_PATH") ?: "release.keystore") storePassword = keystorePwd keyAlias = alias keyPassword = pwd @@ -217,4 +217,4 @@ fun Project.setupApp() { jniLibs.srcDir("executableSo") } } -} \ No newline at end of file +} diff --git a/libcore/assets_android.go b/libcore/assets_android.go index c3c98310d5..3ad30238b2 100644 --- a/libcore/assets_android.go +++ b/libcore/assets_android.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strconv" - "golang.org/x/mobile/asset" + "github.com/sagernet/gomobile/asset" ) func extractAssets() { diff --git a/libcore/box.go b/libcore/box.go index 5df6d4ba0f..b484bb9f77 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -7,36 +7,39 @@ import ( "io" "libcore/device" "log" + "net" + "net/http" "runtime" "runtime/debug" "strings" "sync" + "time" "github.com/matsuridayo/libneko/protect_server" "github.com/matsuridayo/libneko/speedtest" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/boxapi" - "github.com/sagernet/sing-box/experimental/libbox/platform" + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/experimental/v2rayapi" "github.com/sagernet/sing-box/protocol/group" box "github.com/sagernet/sing-box" - "github.com/sagernet/sing-box/common/conntrack" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" + M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" ) -func init() { - dialer.DoNotSelectInterface = true -} - var mainInstance *BoxInstance +var instancesMu sync.Mutex +var instances = make(map[*BoxInstance]adapter.ConnectionManager) +var coreCommit string func VersionBox() string { version := []string{ "sing-box: " + constant.Version, + "commit: " + coreCommit, runtime.Version() + "@" + runtime.GOOS + "/" + runtime.GOARCH, } @@ -60,7 +63,15 @@ func VersionBox() string { func ResetAllConnections(system bool) { if system { - conntrack.Close() + instancesMu.Lock() + managers := make([]adapter.ConnectionManager, 0, len(instances)) + for _, manager := range instances { + managers = append(managers, manager) + } + instancesMu.Unlock() + for _, manager := range managers { + manager.CloseAll() + } log.Println("Reset system connections done") } else { log.Println("TODO: Reset user connections") @@ -68,13 +79,14 @@ func ResetAllConnections(system bool) { } type BoxInstance struct { - access sync.Mutex + access sync.Mutex + selectionMu sync.Mutex *box.Box cancel context.CancelFunc state int - v2api *boxapi.SbV2rayServer + v2api *v2rayapi.StatsService selector *group.Selector pauseManager pause.Manager } @@ -84,21 +96,34 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box // create box context ctx, cancel := context.WithCancel(context.Background()) + defer func() { + if err != nil { + cancel() + } + }() ctx = box.Context(ctx, nekoboxAndroidInboundRegistry(), nekoboxAndroidOutboundRegistry(), nekoboxAndroidEndpointRegistry(), - nekoboxAndroidDNSTransportRegistry(localTransport), nekoboxAndroidServiceRegistry(), + nekoboxAndroidDNSTransportRegistry(localTransport), nekoboxAndroidServiceRegistry(), certificate.NewRegistry(), ) ctx = service.ContextWithDefaultRegistry(ctx) - service.MustRegister[platform.Interface](ctx, boxPlatformInterfaceInstance) + service.MustRegister[adapter.PlatformInterface](ctx, boxPlatformInterfaceInstance) // parse options + config, sniffOverrides, err := sniffCompatibility(config) + if err != nil { + return nil, fmt.Errorf("decode sniff compatibility: %w", err) + } var options option.Options err = options.UnmarshalJSONContext(ctx, []byte(config)) if err != nil { return nil, fmt.Errorf("decode config: %v", err) } + if err = loadGeoRuleSets(&options); err != nil { + return nil, fmt.Errorf("load geo rules: %w", err) + } // create box + applyExternalCertificateOptions(&options) instance, err := box.New(box.Options{ Options: options, Context: ctx, @@ -108,12 +133,19 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box cancel() return nil, fmt.Errorf("create service: %v", err) } + if err = applySniffCompatibility(instance.Router(), sniffOverrides); err != nil { + instance.Close() + return nil, err + } b = &BoxInstance{ Box: instance, cancel: cancel, pauseManager: service.FromContext[pause.Manager](ctx), } + instancesMu.Lock() + instances[b] = service.FromContext[adapter.ConnectionManager](ctx) + instancesMu.Unlock() // selector if proxy, ok := b.Outbound().Outbound("proxy"); ok { @@ -151,8 +183,14 @@ func (b *BoxInstance) Close() (err error) { b.state = 2 // clear main instance - if mainInstance == b { + instancesMu.Lock() + delete(instances, b) + wasMain := mainInstance == b + if wasMain { mainInstance = nil + } + instancesMu.Unlock() + if wasMain { goServeProtect(false) } @@ -181,7 +219,9 @@ func (b *BoxInstance) Wake() { } func (b *BoxInstance) SetAsMain() { + instancesMu.Lock() mainInstance = b + instancesMu.Unlock() goServeProtect(true) } @@ -192,46 +232,76 @@ func (b *BoxInstance) SetV2rayStats(outbounds string) { log.Println("duplicate call of SetV2rayStats") return } - b.v2api = boxapi.NewSbV2rayServer(option.V2RayStatsServiceOptions{ + b.v2api = v2rayapi.NewStatsService(option.V2RayStatsServiceOptions{ Enabled: true, Outbounds: strings.Split(outbounds, "\n"), }) - b.Box.Router().AppendTracker(b.v2api.StatsService()) + b.Box.Router().AppendTracker(b.v2api) } func (b *BoxInstance) QueryStats(tag, direct string) int64 { if b.v2api == nil { return 0 } - return b.v2api.QueryStats(fmt.Sprintf("outbound>>>%s>>>traffic>>>%s", tag, direct)) + response, err := b.v2api.GetStats(context.Background(), &v2rayapi.GetStatsRequest{ + Name: fmt.Sprintf("outbound>>>%s>>>traffic>>>%s", tag, direct), Reset_: true, + }) + if err != nil { + return 0 + } + return response.Stat.Value } func (b *BoxInstance) SelectOutbound(tag string) bool { if b.selector != nil { - return b.selector.SelectOutbound(tag) + b.selectionMu.Lock() + before := b.selector.Now() + selected := b.selector.SelectOutbound(tag) + changed := selected && before != tag + b.selectionMu.Unlock() + // Official SelectOutbound has completed Interrupt before the App resets + // every instance and updates its traffic, notification and Binder state. + if changed && intfNB4A != nil { + intfNB4A.Selector_OnProxySelected(b.selector.Tag(), tag) + } + return selected } return false } func UrlTest(i *BoxInstance, link string, timeout int32) (latency int32, err error) { defer device.DeferPanicToError("box.UrlTest", func(err_ error) { err = err_ }) - var connectionTracker adapter.ConnectionTracker - // test i - if i != nil { - if i.v2api != nil { - connectionTracker = i.v2api.StatsService() - } - return speedtest.UrlTest(boxapi.CreateProxyHttpClient(i.Box, connectionTracker), link, timeout, speedtest.UrlTestStandard_RTT) - } - // test direct - if mainInstance == nil { - return speedtest.UrlTest(boxapi.CreateProxyHttpClient(nil, nil), link, timeout, speedtest.UrlTestStandard_RTT) + if i == nil { + instancesMu.Lock() + i = mainInstance + instancesMu.Unlock() } - // test mainInstance - if mainInstance.v2api != nil { - connectionTracker = mainInstance.v2api.StatsService() + return speedtest.UrlTest(urlTestClient(i), link, timeout, speedtest.UrlTestStandard_RTT) +} + +// StatsService tracks inbound reads as uplink. A URL test owns the outbound +// side: reverse only the accounting orientation, leaving actual I/O unchanged. +type reverseStatsConn struct{ net.Conn } + +func (c *reverseStatsConn) Read(p []byte) (int, error) { return c.Conn.Write(p) } +func (c *reverseStatsConn) Write(p []byte) (int, error) { return c.Conn.Read(p) } + +func urlTestClient(instance *BoxInstance) *http.Client { + transport := &http.Transport{TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: 3 * time.Second} + if instance != nil { + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + outbound := instance.Outbound().Default() + conn, err := dialer.NewDetour(instance.Outbound(), outbound.Tag(), true).DialContext(ctx, network, M.ParseSocksaddr(address)) + if err != nil { + return nil, err + } + if instance.v2api != nil { + conn = &reverseStatsConn{instance.v2api.RoutedConnection(ctx, &reverseStatsConn{conn}, adapter.InboundContext{}, nil, outbound)} + } + return conn, nil + } } - return speedtest.UrlTest(boxapi.CreateProxyHttpClient(mainInstance.Box, connectionTracker), link, timeout, speedtest.UrlTestStandard_RTT) + return &http.Client{Transport: transport} } var protectCloser io.Closer diff --git a/libcore/box_include.go b/libcore/box_include.go index 5a95cd8677..5293869a5d 100644 --- a/libcore/box_include.go +++ b/libcore/box_include.go @@ -19,7 +19,6 @@ import ( "github.com/sagernet/sing-box/protocol/anytls" "github.com/sagernet/sing-box/protocol/block" "github.com/sagernet/sing-box/protocol/direct" - protocolDns "github.com/sagernet/sing-box/protocol/dns" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" "github.com/sagernet/sing-box/protocol/hysteria" @@ -63,7 +62,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { direct.RegisterOutbound(registry) block.RegisterOutbound(registry) - protocolDns.RegisterOutbound(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) @@ -83,8 +81,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { tuic.RegisterOutbound(registry) hysteria2.RegisterOutbound(registry) - wireguard.RegisterOutbound(registry) - return registry } diff --git a/libcore/build.sh b/libcore/build.sh index 233e89d4d4..9e1b829628 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -1,24 +1,23 @@ #!/bin/bash - -source ./env_java.sh || true +set -eo pipefail +export GOTOOLCHAIN=go1.25.5 +cd "$(dirname "$0")" +[ ! -f ./env_java.sh ] || source ./env_java.sh source ../buildScript/init/env_ndk.sh - -BUILD=".build" - -rm -rf $BUILD/android \ - $BUILD/java \ - $BUILD/javac-output \ - $BUILD/src - -if [ -z "$GOPATH" ]; then - GOPATH=$(go env GOPATH) -fi - -export GOBIND=gobind-matsuri -"$GOPATH"/bin/gomobile-matsuri bind -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 -rm -r libcore-sources.jar - -proj=../app/libs -mkdir -p $proj -cp -f libcore.aar $proj -echo ">> install $(realpath $proj)/libcore.aar" +source ../buildScript/lib/core/get_source_env.sh +core=../../sing-box +[ "$(git -C "$core" remote get-url origin)" = https://github.com/SagerNet/sing-box.git ] +[ "$(git -C "$core" rev-parse HEAD)" = "$COMMIT_SING_BOX" ] +[ "$(git -C "$core" rev-parse "v$VERSION_SING_BOX^{commit}")" = "$COMMIT_SING_BOX" ] +[ -z "$(git -C "$core" status --porcelain --untracked-files=all)" ] +bash ./init.sh +# Official gomobile resolves gobind through PATH, not the old GOBIND variable. +export PATH="$PWD/.build/toolchain/bin:$PATH" +gomobile bind -v -target=android -androidapi 21 -trimpath \ + -ldflags="-X github.com/sagernet/sing-box/constant.Version=$VERSION_SING_BOX -X libcore.coreCommit=$COMMIT_SING_BOX -X runtime.godebugDefault=multipathtcp=0,tlssha1=1 -checklinkname=0 -s -w -buildid=" \ + -tags=with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api \ + -o libcore.aar . +[ -z "$(git -C "$core" status --porcelain --untracked-files=all)" ] +mkdir -p ../app/libs +cp libcore.aar ../app/libs/libcore.aar +echo ">> install $(realpath ../app/libs/libcore.aar)" diff --git a/libcore/cert_options.go b/libcore/cert_options.go new file mode 100644 index 0000000000..bb401df98b --- /dev/null +++ b/libcore/cert_options.go @@ -0,0 +1,22 @@ +package libcore + +import "github.com/sagernet/sing-box/option" + +// Official Android uses its own certificate store. Preserve the existing +// external ca.pem replacement, rather than adding those roots to that store. +func applyExternalCertificateOptions(options *option.Options) { + pem := externalRootPEM.Load() + if pem == nil { + return + } + var certificates option.CertificateOptions + if options.Certificate != nil { + certificates = *options.Certificate + if certificates.Store != "" && certificates.Store != "system" { + return + } + } + certificates.Store = "none" + certificates.Certificate = append([]string{*pem}, certificates.Certificate...) + options.Certificate = &certificates +} diff --git a/libcore/certs.go b/libcore/certs.go index 00a98db4df..9f2e30d543 100644 --- a/libcore/certs.go +++ b/libcore/certs.go @@ -3,12 +3,15 @@ package libcore import ( "crypto/x509" "log" + "sync/atomic" _ "unsafe" // for go:linkname ) //go:linkname systemRoots crypto/x509.systemRoots var systemRoots *x509.CertPool +var externalRootPEM atomic.Pointer[string] + func updateRootCACerts(pem []byte) { x509.SystemCertPool() roots := x509.NewCertPool() @@ -17,6 +20,8 @@ func updateRootCACerts(pem []byte) { return } systemRoots = roots + value := string(pem) + externalRootPEM.Store(&value) log.Println("external ca.pem was loaded") } diff --git a/libcore/compat_host_test.go b/libcore/compat_host_test.go new file mode 100644 index 0000000000..9e5208bebf --- /dev/null +++ b/libcore/compat_host_test.go @@ -0,0 +1,381 @@ +//go:build !android + +package libcore + +// Run with the explicit production file list in the Phase 1 audit runner. +// Only Android platform I/O is replaced; BoxInstance and compatibility logic +// below are the same files compiled into libcore.aar. +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/certificate" + geosites "github.com/sagernet/sing-box/common/geosite" + "github.com/sagernet/sing-box/common/sniff" + sblog "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route/rule" + M "github.com/sagernet/sing/common/metadata" +) + +var boxPlatformInterfaceInstance adapter.PlatformInterface +var boxPlatformLogWriter sblog.PlatformWriter +var externalAssetsPath string + +func TestProductionURLTestRTT(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); w.WriteHeader(http.StatusNoContent) })) + defer server.Close() + b := testBox(t, `{"outbounds":[{"type":"direct","tag":"direct"}]}`) + if _, err := UrlTest(b, server.URL, 3000); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("RTT request count=%d want=2", requests.Load()) + } +} + +func TestOfficialGeoAssets(t *testing.T) { + root := os.Getenv("NEKO_GEO_ASSETS") + if root == "" { + t.Skip("official asset directory required") + } + previous := externalAssetsPath + externalAssetsPath = root + defer func() { externalAssetsPath = previous }() + b := testBox(t, `{"route":{"rule_set":[{"type":"local","tag":"ip","format":"binary","path":"geoip:us"},{"type":"local","tag":"site","format":"binary","path":"geosite:github"}],"rules":[{"rule_set":"ip","outbound":"direct"},{"rule_set":"site","outbound":"direct"}]},"outbounds":[{"type":"direct","tag":"direct"}]}`) + for _, test := range []struct { + index int + destination string + want bool + }{{0, "8.8.8.8:443", true}, {0, "10.1.2.3:443", false}, {1, "github.com:443", true}, {1, "unrelated.invalid:443", false}} { + destination := M.ParseSocksaddr(test.destination) + metadata := adapter.InboundContext{Destination: destination, Domain: destination.Fqdn} + if got := b.Router().Rules()[test.index].Match(&metadata); got != test.want { + t.Fatalf("%s match=%v want=%v", test.destination, got, test.want) + } + } +} + +func TestExternalCertificateReplacement(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + root, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + encoded := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + previous := externalRootPEM.Swap(&encoded) + defer externalRootPEM.Store(previous) + for _, kind := range []string{"", "system", "none", "mozilla"} { + original := &option.CertificateOptions{Store: kind} + options := option.Options{Certificate: original} + applyExternalCertificateOptions(&options) + if kind == "none" || kind == "mozilla" { + if options.Certificate != original { + t.Fatal("explicit store changed") + } + continue + } + if original.Store != kind || len(original.Certificate) != 0 { + t.Fatal("input options mutated") + } + store, err := certificate.NewStore(context.Background(), sblog.NewNOPFactory().Logger(), *options.Certificate) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if len(store.Pool().Subjects()) != 1 || !store.ExclusiveAnchors() { + t.Fatal("external roots must replace system roots") + } + if _, err = root.Verify(x509.VerifyOptions{Roots: store.Pool()}); err != nil { + t.Fatal(err) + } + } +} + +func TestGeoSiteCompatibilityMatches(t *testing.T) { + previous := externalAssetsPath + externalAssetsPath = t.TempDir() + defer func() { externalAssetsPath = previous }() + var data bytes.Buffer + if err := geosites.Write(&data, map[string][]geosites.Item{"fixture": {{Type: geosites.RuleTypeDomain, Value: "example.org"}}}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(externalAssetsPath, "geosite.db"), data.Bytes(), 0600); err != nil { + t.Fatal(err) + } + b := testBox(t, `{"route":{"rule_set":[{"type":"local","tag":"geo","format":"binary","path":"geosite:fixture"}],"rules":[{"rule_set":"geo","outbound":"direct"}]},"outbounds":[{"type":"direct","tag":"direct"}]}`) + for domain, want := range map[string]bool{"example.org": true, "other.invalid": false} { + metadata := adapter.InboundContext{Domain: domain, Destination: M.ParseSocksaddr(domain + ":443")} + if got := b.Router().Rules()[0].Match(&metadata); got != want { + t.Fatalf("%s match=%v want=%v", domain, got, want) + } + } +} + +func TestGeoSiteLoadsCloseTheirFiles(t *testing.T) { + if _, err := os.Stat("/proc/self/fd"); err != nil { + t.Skip("requires Linux file descriptor inspection") + } + previous := externalAssetsPath + externalAssetsPath = t.TempDir() + defer func() { externalAssetsPath = previous }() + var data bytes.Buffer + if err := geosites.Write(&data, map[string][]geosites.Item{"fixture": {{Type: geosites.RuleTypeDomain, Value: "example.org"}}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(externalAssetsPath, "geosite.db") + if err := os.WriteFile(path, data.Bytes(), 0600); err != nil { + t.Fatal(err) + } + for range 64 { + if _, err := geoSiteHeadlessRules("fixture"); err != nil { + t.Fatal(err) + } + if _, err := geoSiteHeadlessRules("missing"); err == nil { + t.Fatal("missing code should fail") + } + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + target, _ := os.Readlink(filepath.Join("/proc/self/fd", entry.Name())) + if target == path { + t.Fatalf("GeoSite file descriptor was leaked: %s", entry.Name()) + } + } +} + +func testBox(t *testing.T, config string) *BoxInstance { + t.Helper() + b, err := NewSingBoxInstance(config, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { b.Close() }) + if err = b.Start(); err != nil { + t.Fatal(err) + } + return b +} + +type testCallback struct{ callback func(string, string) } + +func (c testCallback) UseOfficialAssets() bool { return true } +func (c testCallback) Selector_OnProxySelected(group, tag string) { c.callback(group, tag) } + +func TestProductionSelectorResetAndOutboundStats(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + data := make([]byte, 4) + if _, err := io.ReadFull(conn, data); err == nil { + conn.Write([]byte("answer")) + io.Copy(io.Discard, conn) + } + }() + } + }() + config := `{"outbounds":[{"type":"selector","tag":"proxy","outbounds":["a","b"]},{"type":"direct","tag":"a"},{"type":"direct","tag":"b"}]}` + first := testBox(t, config) + second := testBox(t, config) + first.SetV2rayStats("proxy\na\nb") + open := func(b *BoxInstance) net.Conn { + conn, err := urlTestClient(b).Transport.(*http.Transport).DialContext(context.Background(), "tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + conn.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err = conn.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + data := make([]byte, 6) + if _, err = io.ReadFull(conn, data); err != nil { + t.Fatal(err) + } + if string(data) != "answer" { + t.Fatal(string(data)) + } + return conn + } + a, b := open(first), open(second) + if n := first.QueryStats("proxy", "uplink"); n != 4 { + t.Fatalf("uplink=%d", n) + } + if n := first.QueryStats("proxy", "downlink"); n != 6 { + t.Fatalf("downlink=%d", n) + } + if n := first.QueryStats("proxy", "downlink"); n != 0 { + t.Fatalf("reset-on-read=%d", n) + } + count := 0 + intfNB4A = testCallback{func(group, tag string) { + count++ + if first.selector.Now() != tag || group != "proxy" { + t.Error("callback state") + } + ResetAllConnections(true) + for _, conn := range []net.Conn{a, b} { + if _, err := conn.Write([]byte("closed")); err == nil { + t.Error("global reset left connection open") + } + } + open(second) // Must survive the already-completed selector Interrupt. + }} + t.Cleanup(func() { intfNB4A = nil }) + if !first.SelectOutbound("b") || count != 1 { + t.Fatal("switch callback missing") + } + if !first.SelectOutbound("b") || count != 1 { + t.Fatal("same-tag callback changed") + } + if first.SelectOutbound("missing") || count != 1 { + t.Fatal("invalid-tag callback changed") + } +} + +func TestProductionSniffOverride(t *testing.T) { + first, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer first.Close() + port := first.Addr().(*net.TCPAddr).Port + second, err := net.Listen("tcp", fmt.Sprintf("127.0.0.2:%d", port)) + if err != nil { + t.Fatal(err) + } + defer second.Close() + for name, listener := range map[string]net.Listener{"sniffed": first, "original": second} { + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { defer conn.Close(); buffer := make([]byte, 4096); conn.Read(buffer); conn.Write([]byte(name)) }() + } + }() + } + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprint(enabled), func(t *testing.T) { + config := fmt.Sprintf(`{"dns":{"servers":[{"type":"hosts","tag":"hosts","predefined":{"sniff.test":"127.0.0.1"}}]},"route":{"default_domain_resolver":"hosts","rules":[{"action":"sniff","_neko_sniff_override":%v}]},"outbounds":[{"type":"direct","tag":"direct"}]}`, enabled) + b := testBox(t, config) + // Test HTTP extraction locally. Windows HTTP filtering intercepts + // loopback Host headers, so use a plain payload for the routing leg. + metadata := adapter.InboundContext{} + if err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader("GET / HTTP/1.1\r\nHost: sniff.test\r\n\r\n")); err != nil || metadata.Domain != "sniff.test" { + t.Fatalf("HTTP sniff: %v", err) + } + action := b.Router().Rules()[0].Action().(*rule.RuleActionSniff) + action.StreamSniffers = []sniff.StreamSniffer{func(_ context.Context, m *adapter.InboundContext, reader io.Reader) error { + m.Domain = metadata.Domain + m.Protocol = "http" + return nil + }} + client, server := net.Pipe() + defer client.Close() + go b.Router().RouteConnectionEx(context.Background(), server, adapter.InboundContext{Network: "tcp", Destination: M.ParseSocksaddr(fmt.Sprintf("127.0.0.2:%d", port))}, nil) + client.SetDeadline(time.Now().Add(4 * time.Second)) + if _, err := client.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(client) + if err != nil { + t.Fatal(err) + } + want := "original" + if enabled { + want = "sniffed" + } + if string(data) != want { + t.Fatalf("got %.100q want %q", data, want) + } + }) + } +} + +func TestCustomLegacyConfigStillRejected(t *testing.T) { + config := `{"inbounds":[{"type":"mixed","sniff":true}]}` + if _, err := NewSingBoxInstance(config, nil); err == nil || !strings.Contains(err.Error(), "legacy inbound") { + t.Fatalf("custom config was silently rewritten: %v", err) + } +} + +func TestDNSBaselineSuccessCallbackCompletes(t *testing.T) { + called := false + c := ExchangeContext{done: func() { called = true }} + c.Success("127.0.0.1") + if !called || len(c.addresses) != 1 { + t.Fatal("successful callback must publish addresses and complete") + } +} + +func TestActualAppGeneratedConfigs(t *testing.T) { + root := os.Getenv("NEKO_CONFIG_FIXTURES") + if root == "" { + t.Skip("set NEKO_CONFIG_FIXTURES to the actual JVM ConfigBuilder output") + } + files, err := filepath.Glob(filepath.Join(root, "*.json")) + if err != nil || len(files) == 0 { + t.Fatal("missing generated configs", err) + } + for _, file := range files { + t.Run(filepath.Base(file), func(t *testing.T) { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + b, err := NewSingBoxInstance(string(data), nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(filepath.Base(file), "urltest") { + if err := b.Start(); err != nil { + b.Close() + t.Fatal(err) + } + } + b.Close() + }) + } +} diff --git a/libcore/dns_box.go b/libcore/dns_box.go index 3f1cd5fbbb..1635854f7d 100644 --- a/libcore/dns_box.go +++ b/libcore/dns_box.go @@ -54,6 +54,15 @@ func (p *platformLocalDNSTransport) Close() error { return nil } +func (p *platformLocalDNSTransport) Reset() {} + +func (p *platformLocalDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(*mDNS.Msg, error)) { + go func() { + response, err := p.Exchange(ctx, message) + callback(response, err) + }() +} + func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if p.raw && rawQueryFunc != nil { // Raw - Android 10 及以上才有 @@ -74,6 +83,8 @@ func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS. return responseMessage, nil } else { // Lookup - Android 10 以下 + ctx, cancel := context.WithCancel(ctx) + defer cancel() // Release cancellation listeners even after Lookup errors. question := message.Question[0] var network string @@ -88,7 +99,8 @@ func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS. done := make(chan struct{}) response := &ExchangeContext{ - context: ctx, + context: ctx, + finished: done, done: sync.OnceFunc(func() { close(done) }), @@ -104,7 +116,7 @@ func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS. select { case <-done: case <-ctx.Done(): - return context.Canceled + return response.context.Err() } if response.error != nil { return response.error @@ -130,37 +142,56 @@ type ExchangeContext struct { addresses []netip.Addr error error done func() + completed sync.Once + finished <-chan struct{} } func (c *ExchangeContext) OnCancel(callback Func) { go func() { - <-c.context.Done() - callback.Invoke() + select { + case <-c.context.Done(): + select { + case <-c.finished: + return + default: + } + callback.Invoke() + case <-c.finished: + } }() } func (c *ExchangeContext) Success(result string) { - c.addresses = common.Map(common.Filter(strings.Split(result, "\n"), func(it string) bool { - return !common.IsEmpty(it) - }), func(it string) netip.Addr { - return M.ParseSocksaddrHostPort(it, 0).Unwrap().Addr + c.completed.Do(func() { + c.addresses = common.Map(common.Filter(strings.Split(result, "\n"), func(it string) bool { + return !common.IsEmpty(it) + }), func(it string) netip.Addr { + return M.ParseSocksaddrHostPort(it, 0).Unwrap().Addr + }) + c.done() }) } func (c *ExchangeContext) RawSuccess(result []byte) { - err := c.message.Unpack(result) - if err != nil { - c.error = E.Cause(err, "parse response") - } - c.done() + c.completed.Do(func() { + err := c.message.Unpack(result) + if err != nil { + c.error = E.Cause(err, "parse response") + } + c.done() + }) } func (c *ExchangeContext) ErrorCode(code int32) { - c.error = dns.RcodeError(code) - c.done() + c.completed.Do(func() { + c.error = dns.RcodeError(code) + c.done() + }) } func (c *ExchangeContext) ErrnoCode(code int32) { - c.error = syscall.Errno(code) - c.done() + c.completed.Do(func() { + c.error = syscall.Errno(code) + c.done() + }) } diff --git a/libcore/dns_callback_test.go b/libcore/dns_callback_test.go new file mode 100644 index 0000000000..a49e45e4c5 --- /dev/null +++ b/libcore/dns_callback_test.go @@ -0,0 +1,309 @@ +//go:build !android + +package libcore + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + mDNS "github.com/miekg/dns" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/option" +) + +type callbackResolver struct{ lookup func(*ExchangeContext) error } + +func (r callbackResolver) Raw() bool { return false } +func (r callbackResolver) NetworkHandle() int64 { return 0 } +func (r callbackResolver) Lookup(c *ExchangeContext, network, domain string) error { + return r.lookup(c) +} +func (r callbackResolver) Exchange(*ExchangeContext, []byte) error { + return errors.New("unexpected raw exchange") +} + +type cancelFunc func() error + +func (f cancelFunc) Invoke() error { return f() } + +func lookupWith(t *testing.T, ctx context.Context, f func(*ExchangeContext) error) (*mDNS.Msg, error) { + t.Helper() + q := new(mDNS.Msg) + q.SetQuestion("fixture.invalid.", mDNS.TypeA) + return newPlatformTransport(callbackResolver{f}, "fixture", option.LocalDNSServerOptions{}).Exchange(ctx, q) +} +func checkAddress(t *testing.T, msg *mDNS.Msg, want ...string) { + t.Helper() + if msg == nil || len(msg.Answer) != len(want) { + t.Fatalf("answer=%v want=%v", msg, want) + } + for i, v := range want { + record, ok := msg.Answer[i].(*mDNS.A) + if !ok || record.A.String() != v { + t.Fatalf("answer[%d]=%v want=%s", i, msg.Answer[i], v) + } + } +} +func TestDNSCallbackSuccess(t *testing.T) { + for _, addresses := range []string{"192.0.2.1", "192.0.2.1\n192.0.2.2"} { + t.Run(addresses, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + defer cancel() + start := time.Now() + msg, err := lookupWith(t, ctx, func(c *ExchangeContext) error { c.Success(addresses); return nil }) + if err != nil { + t.Fatal(err) + } + if time.Since(start) > 400*time.Millisecond { + t.Fatal("success waited for deadline") + } + if addresses == "192.0.2.1" { + checkAddress(t, msg, "192.0.2.1") + } else { + checkAddress(t, msg, "192.0.2.1", "192.0.2.2") + } + }) + } +} +func TestDNSCallbackErrors(t *testing.T) { + sentinel := errors.New("lookup failed") + for _, test := range []struct { + name string + f func(*ExchangeContext) error + want error + }{ + {"NXDOMAIN", func(c *ExchangeContext) error { c.ErrorCode(mDNS.RcodeNameError); return nil }, dns.RcodeError(mDNS.RcodeNameError)}, + {"errno", func(c *ExchangeContext) error { c.ErrnoCode(int32(syscall.EIO)); return nil }, syscall.EIO}, + {"lookup-error", func(c *ExchangeContext) error { return sentinel }, sentinel}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := time.Now() + _, err := lookupWith(t, ctx, test.f) + if !errors.Is(err, test.want) { + t.Fatalf("got %v want %v", err, test.want) + } + if time.Since(start) > 500*time.Millisecond { + t.Fatal("error waited for deadline") + } + }) + } +} +func TestDNSCallbackCancellation(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(fmt.Sprint(deadline), func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + if deadline { + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Millisecond) + } + defer cancel() + started, stopped := make(chan struct{}), make(chan struct{}) + done := make(chan error, 1) + go func() { + _, err := lookupWith(t, ctx, func(c *ExchangeContext) error { + c.OnCancel(cancelFunc(func() error { close(stopped); return nil })) + close(started) + return nil + }) + done <- err + }() + <-started + if !deadline { + cancel() + } + select { + case err := <-done: + want := context.Canceled + if deadline { + want = context.DeadlineExceeded + } + if !errors.Is(err, want) { + t.Fatalf("got %v want %v", err, want) + } + case <-time.After(time.Second): + t.Fatal("cancellation hung") + } + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("cancellation listener leaked") + } + }) + } +} +func TestDNSCallbackDuplicate(t *testing.T) { + for _, test := range []struct { + name string + calls func(*ExchangeContext) + failure bool + }{ + {"success-success", func(c *ExchangeContext) { c.Success("192.0.2.1"); c.Success("192.0.2.2") }, false}, + {"success-error", func(c *ExchangeContext) { + c.Success("192.0.2.1") + c.ErrorCode(mDNS.RcodeNameError) + c.ErrnoCode(int32(syscall.EIO)) + }, false}, + {"error-success", func(c *ExchangeContext) { c.ErrorCode(mDNS.RcodeNameError); c.Success("192.0.2.1") }, true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + msg, err := lookupWith(t, ctx, func(c *ExchangeContext) error { test.calls(c); return nil }) + if test.failure { + if !errors.Is(err, dns.RcodeError(mDNS.RcodeNameError)) { + t.Fatal(err) + } + } else { + if err != nil { + t.Fatal(err) + } + checkAddress(t, msg, "192.0.2.1") + } + }) + } +} +func TestDNSCallbackConcurrentCancellation(t *testing.T) { + for i := 0; i < 500; i++ { + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan struct{}) + var workers sync.WaitGroup + msg, err := lookupWith(t, ctx, func(c *ExchangeContext) error { + workers.Add(3) + go func() { defer workers.Done(); <-ready; c.Success("192.0.2.1") }() + go func() { defer workers.Done(); <-ready; cancel() }() + go func() { defer workers.Done(); <-ready; c.ErrorCode(mDNS.RcodeNameError); c.Success("192.0.2.2") }() + close(ready) + return nil + }) + workers.Wait() + cancel() + if err == nil { + checkAddress(t, msg, "192.0.2.1") + } else if !errors.Is(err, context.Canceled) && !errors.Is(err, dns.RcodeError(mDNS.RcodeNameError)) { + t.Fatal(err) + } + } +} +func TestDNSCallbackNoWatcherLeak(t *testing.T) { + before := runtime.NumGoroutine() + var canceled atomic.Int32 + for i := 0; i < 200; i++ { + _, err := lookupWith(t, context.Background(), func(c *ExchangeContext) error { + c.OnCancel(cancelFunc(func() error { canceled.Add(1); return nil })) + c.Success("192.0.2.1") + return nil + }) + if err != nil { + t.Fatal(err) + } + } + deadline := time.Now().Add(2 * time.Second) + for runtime.NumGoroutine() > before+5 && time.Now().Before(deadline) { + runtime.GC() + time.Sleep(10 * time.Millisecond) + } + if n := runtime.NumGoroutine(); n > before+5 { + t.Fatalf("watchers leaked: before=%d after=%d", before, n) + } +} +func TestDNSRawCallbackFirstCompletion(t *testing.T) { + msg := new(mDNS.Msg) + msg.SetQuestion("fixture.invalid.", mDNS.TypeA) + raw, err := msg.Pack() + if err != nil { + t.Fatal(err) + } + var completed atomic.Int32 + c := ExchangeContext{done: func() { completed.Add(1) }} + c.RawSuccess(raw) + c.ErrorCode(mDNS.RcodeNameError) + c.RawSuccess([]byte{1}) + if completed.Load() != 1 || c.error != nil || len(c.message.Question) != 1 { + t.Fatal("raw callback overwritten or completed twice") + } +} + +func TestDNSCallbackLookupErrorReleasesWatcher(t *testing.T) { + stopped := make(chan struct{}) + sentinel := errors.New("lookup rejected") + _, err := lookupWith(t, context.Background(), func(c *ExchangeContext) error { + c.OnCancel(cancelFunc(func() error { close(stopped); return nil })) + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatal(err) + } + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("lookup error leaked watcher") + } +} + +func TestDNSCallbackConcurrentSuccess(t *testing.T) { + for i := 0; i < 200; i++ { + var workers sync.WaitGroup + var completed atomic.Int32 + done := make(chan struct{}) + c := ExchangeContext{done: func() { completed.Add(1); close(done) }} + workers.Add(2) + for _, address := range []string{"192.0.2.1", "192.0.2.2"} { + go func(address string) { defer workers.Done(); c.Success(address) }(address) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("callback hung") + } + if len(c.addresses) != 1 { + t.Fatal("result missing after completion") + } + first := c.addresses[0] + workers.Wait() + if completed.Load() != 1 || c.addresses[0] != first { + t.Fatal("duplicate changed result") + } + } +} + +func TestDNSCallbackExchangeAsync(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var resolverWorkers sync.WaitGroup + transport := newPlatformTransport(callbackResolver{func(c *ExchangeContext) error { + resolverWorkers.Add(1) + go func() { defer resolverWorkers.Done(); c.Success("192.0.2.1"); c.Success("192.0.2.2") }() + return nil + }}, "fixture", option.LocalDNSServerOptions{}) + question := new(mDNS.Msg) + question.SetQuestion("fixture.invalid.", mDNS.TypeA) + done := make(chan struct{}) + var called atomic.Int32 + transport.ExchangeAsync(ctx, question, func(msg *mDNS.Msg, err error) { + if err != nil { + t.Error(err) + } else { + checkAddress(t, msg, "192.0.2.1") + } + called.Add(1) + close(done) + }) + select { + case <-done: + case <-ctx.Done(): + t.Fatal("ExchangeAsync waited for timeout") + } + resolverWorkers.Wait() + if called.Load() != 1 { + t.Fatal("duplicate async completion") + } +} diff --git a/libcore/geo_rules.go b/libcore/geo_rules.go new file mode 100644 index 0000000000..6c8ec0cb3f --- /dev/null +++ b/libcore/geo_rules.go @@ -0,0 +1,39 @@ +package libcore + +import ( + "fmt" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +// Preserve the App's geoip:/geosite: local paths at the libcore boundary. +// The official loader receives ordinary inline rules, never a Core hook. +func loadGeoRuleSets(options *option.Options) error { + if options.Route == nil { + return nil + } + for index := range options.Route.RuleSet { + ruleSet := &options.Route.RuleSet[index] + if ruleSet.Type != C.RuleSetTypeLocal { + continue + } + path := ruleSet.LocalOptions.Path + var rules []option.HeadlessRule + var err error + switch { + case strings.HasPrefix(path, "geoip:"): + rules, err = geoIPHeadlessRules(strings.TrimPrefix(path, "geoip:")) + case strings.HasPrefix(path, "geosite:"): + rules, err = geoSiteHeadlessRules(strings.TrimPrefix(path, "geosite:")) + default: + continue + } + if err != nil { + return fmt.Errorf("%s: %w", ruleSet.Tag, err) + } + *ruleSet = option.RuleSet{Type: C.RuleSetTypeInline, Tag: ruleSet.Tag, InlineOptions: option.PlainRuleSet{Rules: rules}} + } + return nil +} diff --git a/libcore/geoip.go b/libcore/geoip.go index 6ea92d5649..3ee00fa9ec 100644 --- a/libcore/geoip.go +++ b/libcore/geoip.go @@ -2,13 +2,11 @@ package libcore import ( "fmt" - "net" "path/filepath" "strings" "github.com/oschwald/maxminddb-golang" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/nekoutils" "github.com/sagernet/sing-box/option" ) @@ -24,31 +22,27 @@ func (g *geoip) Open(path string) error { func (g *geoip) Rules(countryCode string) ([]option.HeadlessRule, error) { networks := g.geoipReader.Networks(maxminddb.SkipAliasedNetworks) - countryMap := make(map[string][]*net.IPNet) - var ( - ipNet *net.IPNet - nextCountryCode string - err error - ) + countryCode = strings.ToLower(countryCode) + var cidrs []string for networks.Next() { - ipNet, err = networks.Network(&nextCountryCode) + var nextCountryCode string + ipNet, err := networks.Network(&nextCountryCode) if err != nil { return nil, fmt.Errorf("failed to get network: %w", err) } - countryMap[nextCountryCode] = append(countryMap[nextCountryCode], ipNet) + if nextCountryCode == countryCode { + cidrs = append(cidrs, ipNet.String()) + } } - - ipNets := countryMap[strings.ToLower(countryCode)] - - if len(ipNets) == 0 { + if err := networks.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate networks: %w", err) + } + if len(cidrs) == 0 { return nil, fmt.Errorf("no networks found for country code: %s", countryCode) } var headlessRule option.DefaultHeadlessRule - headlessRule.IPCIDR = make([]string, 0, len(ipNets)) - for _, cidr := range ipNets { - headlessRule.IPCIDR = append(headlessRule.IPCIDR, cidr.String()) - } + headlessRule.IPCIDR = cidrs return []option.HeadlessRule{ { @@ -58,13 +52,11 @@ func (g *geoip) Rules(countryCode string) ([]option.HeadlessRule, error) { }, nil } -func init() { - nekoutils.GetGeoIPHeadlessRules = func(name string) ([]option.HeadlessRule, error) { - g := new(geoip) - if err := g.Open(filepath.Join(externalAssetsPath, "geoip.db")); err != nil { - return nil, err - } - defer g.geoipReader.Close() - return g.Rules(name) +func geoIPHeadlessRules(name string) ([]option.HeadlessRule, error) { + g := new(geoip) + if err := g.Open(filepath.Join(externalAssetsPath, "geoip.db")); err != nil { + return nil, err } + defer g.geoipReader.Close() + return g.Rules(name) } diff --git a/libcore/geosite.go b/libcore/geosite.go index 616edf13d2..1a48a99c6f 100644 --- a/libcore/geosite.go +++ b/libcore/geosite.go @@ -2,22 +2,31 @@ package libcore import ( "fmt" + "os" "path/filepath" geosites "github.com/sagernet/sing-box/common/geosite" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/nekoutils" "github.com/sagernet/sing-box/option" ) type geosite struct { geositeReader *geosites.Reader + file *os.File } func (g *geosite) Open(path string) error { - geositeReader, _, err := geosites.Open(path) - g.geositeReader = geositeReader - return err + file, err := os.Open(path) + if err != nil { + return err + } + reader, _, err := geosites.NewReader(file) + if err != nil { + file.Close() + return err + } + g.file, g.geositeReader = file, reader + return nil } func (g *geosite) Rules(code string) ([]option.HeadlessRule, error) { @@ -43,12 +52,11 @@ func (g *geosite) Rules(code string) ([]option.HeadlessRule, error) { }, nil } -func init() { - nekoutils.GetGeoSiteHeadlessRules = func(name string) ([]option.HeadlessRule, error) { - g := new(geosite) - if err := g.Open(filepath.Join(externalAssetsPath, "geosite.db")); err != nil { - return nil, err - } - return g.Rules(name) +func geoSiteHeadlessRules(name string) ([]option.HeadlessRule, error) { + g := new(geosite) + if err := g.Open(filepath.Join(externalAssetsPath, "geosite.db")); err != nil { + return nil, err } + defer g.file.Close() + return g.Rules(name) } diff --git a/libcore/go.mod b/libcore/go.mod index ddf89a1f6a..9e60dc5cab 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -1,87 +1,100 @@ module libcore -go 1.23.1 - -toolchain go1.23.6 +go 1.25.5 require ( github.com/matsuridayo/libneko v1.0.0 // replaced - github.com/miekg/dns v1.1.67 + github.com/miekg/dns v1.1.72 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 - github.com/sagernet/sing v0.7.18 - github.com/sagernet/sing-box v1.0.0 // replaced - github.com/sagernet/sing-tun v0.7.10 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 + github.com/sagernet/sing v0.9.0-beta.4 + github.com/sagernet/sing-box v1.14.0 // replaced by the verified official checkout + github.com/sagernet/sing-tun v0.9.0-beta.4 github.com/ulikunitz/xz v0.5.15 - golang.org/x/mobile v0.0.0-20231108233038-35478a0c49da - golang.org/x/sys v0.35.0 + golang.org/x/sys v0.47.0 ) require ( github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/anytls/sing-anytls v0.0.11 // indirect - github.com/caddyserver/certmagic v0.23.0 // indirect - github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect github.com/cretz/bine v0.2.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/gofrs/uuid/v5 v5.5.1 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/libdns/alidns v1.0.5-libdns.v1.beta1 // indirect - github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 // indirect - github.com/libdns/libdns v1.1.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/alidns v1.0.6 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/socket v0.5.1 // indirect - github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 // indirect - github.com/metacubex/utls v1.8.4 // indirect - github.com/mholt/acmez/v3 v3.1.2 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect + github.com/mdlayher/netlink v1.11.2 // indirect + github.com/mdlayher/socket v0.6.0 // indirect + github.com/metacubex/utls v1.8.7 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c // indirect github.com/sagernet/cors v1.2.1 // indirect - github.com/sagernet/fswatch v0.1.1 // indirect - github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect - github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-beta.4 // indirect - github.com/sagernet/sing-mux v0.3.4 // indirect - github.com/sagernet/sing-quic v0.5.2 // indirect + github.com/sagernet/fswatch v0.1.2 // indirect + github.com/sagernet/gomobile v0.1.12 + github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 // indirect + github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf // indirect + github.com/sagernet/nftables v0.3.0-mod.4 // indirect + github.com/sagernet/sing-mux v0.3.5 // indirect + github.com/sagernet/sing-quic v0.7.0-beta.4 // indirect github.com/sagernet/sing-shadowsocks v0.2.8 // indirect github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect - github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-vmess v0.2.7 // indirect + github.com/sagernet/sing-shadowtls v0.2.1 // indirect + github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect + github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30 // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect - google.golang.org/grpc v1.73.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) +require ( + github.com/database64128/netx-go v0.1.1 // indirect + github.com/database64128/tfo-go/v2 v2.3.2 // indirect + github.com/florianl/go-nfqueue/v2 v2.1.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jsimonetti/rtnetlink v1.4.1 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/libdns/acmedns v0.5.0 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect + github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca // indirect +) + replace github.com/matsuridayo/libneko => ../../libneko replace github.com/sagernet/sing-box => ../../sing-box diff --git a/libcore/go.sum b/libcore/go.sum index dcef8d1db6..ff23994f1e 100644 --- a/libcore/go.sum +++ b/libcore/go.sum @@ -1,26 +1,38 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= -github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= -github.com/caddyserver/certmagic v0.23.0/go.mod h1:9mEZIWqqWoI+Gf+4Trh04MOVPD0tGSxtqsxg87hAIH4= -github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= -github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4= +github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= +github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= +github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= +github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/florianl/go-nfqueue/v2 v2.1.0 h1:Fywt30TY/evxyDySpXjxQ1jsRW7nQbLpOhELqpr4068= +github.com/florianl/go-nfqueue/v2 v2.1.0/go.mod h1:8PKUM5rYoVFO5IZV1bifx4/b0jHAglKkHXr9PRwzi4Y= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -29,90 +41,113 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= -github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.5.1 h1:z1Ce19/JwNidXpy3tOQc3241lnJLKdKyq/xlNvlD4Ng= +github.com/gofrs/uuid/v5 v5.5.1/go.mod h1:bbAA98EoIlxyRHIVg6ektCSsZ5n8mSbwgEhvhMYlZgg= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/libdns/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ= -github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g= -github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 h1:3MGrVWs2COjMkQR17oUw1zMIPbm2YAzxDC3oGVZvQs8= -github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= -github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= -github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= -github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= +github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= +github.com/jsimonetti/rtnetlink/v2 v2.2.0 h1:/KfZ310gOAFrXXol5VwnFEt+ucldD/0dsSRZwpHCP9w= +github.com/jsimonetti/rtnetlink/v2 v2.2.0/go.mod h1:lbjDHxC+5RJ08lzPeA90Ls2pEoId3F08MoEMlhfHxeI= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= +github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= +github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= +github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= -github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= -github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 h1:Ui+/2s5Qz0lSnDUBmEL12M5Oi/PzvFxGTNohm8ZcsmE= -github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= -github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= -github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= -github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI= +github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA= +github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= +github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= +github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= +github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= -github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c h1:JdEDkgiwsDxiZb4+dQYgVOU/lND+jZxFmN5y/q49EXk= +github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= -github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= -github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb h1:pprQtDqNgqXkRsXn+0E8ikKOemzmum8bODjSfDene38= -github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= -github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= -github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= -github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= -github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w= -github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4= -github.com/sagernet/sing v0.7.18 h1:iZHkaru1/MoHugx3G+9S3WG4owMewKO/KvieE2Pzk4E= -github.com/sagernet/sing v0.7.18/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= -github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.5.2 h1:I3vlfRImhr0uLwRS3b3ib70RMG9FcXtOKKUDz3eKRWc= -github.com/sagernet/sing-quic v0.5.2/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI= +github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= +github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= +github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY= +github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 h1:IdQ7yTKkB2wv8txwshxUroPlO4npOYAV71xb7xQ7Lys= +github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1/go.mod h1:9O3SQskYuCfdHNvHEsWuEAgoyKEF74PiWp4NsNUia8g= +github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf h1:b/3rm+KvxzuYRcnMdLq/hmcqX6/2B8k24l6SDHL2Piw= +github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= +github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= +github.com/sagernet/sing v0.9.0-beta.4 h1:DYoEzb3FeCZ9bA66hJmL0NFDXckZpa4oYF/VpbFJEkE= +github.com/sagernet/sing v0.9.0-beta.4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= +github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.7.0-beta.4 h1:frzDiZqUbT0yLVaVfgaLtoVQ/zGa3xIsDTHP+aSM/W8= +github.com/sagernet/sing-quic v0.7.0-beta.4/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= -github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= -github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.7.10 h1:lLBaS9uL0mK/FCGDe3N4oKQxjMGfmv3u2/6jKtmq4Pw= -github.com/sagernet/sing-tun v0.7.10/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= -github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= -github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= +github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= +github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-tun v0.9.0-beta.4 h1:gIIZU4HevhtTQubZiOdMD0/RnECD6csl2kG6666KYmQ= +github.com/sagernet/sing-tun v0.9.0-beta.4/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0= +github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A= +github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/wireguard-go v0.0.1-beta.7 h1:ltgBwYHfr+9Wz1eG59NiWnHrYEkDKHG7otNZvu85DXI= -github.com/sagernet/wireguard-go v0.0.1-beta.7/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo= +github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30 h1:Z9QAr893OvR9qDHvxYTsYZhpvqwcc0LWbJ4ECg/eBgg= +github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -123,69 +158,82 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/mobile v0.0.0-20231108233038-35478a0c49da h1:gS9sVMAeHM+gVBmM9bTM6vUi/NHv58O3QzJ3vjjN84M= -golang.org/x/mobile v0.0.0-20231108233038-35478a0c49da/go.mod h1:IEceR0jfVklLJXrbUe90rfdAFAYDW0SQwKl4qvO1GBQ= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/libcore/init.sh b/libcore/init.sh index 50bf4cfbd7..a7ec0f3a37 100755 --- a/libcore/init.sh +++ b/libcore/init.sh @@ -1,28 +1,24 @@ #!/bin/bash - -chmod -R 777 .build 2>/dev/null -rm -rf .build 2>/dev/null - -if [ -z "$GOPATH" ]; then - GOPATH=$(go env GOPATH) +set -eo pipefail +export GOTOOLCHAIN=go1.25.5 +cd "$(dirname "$0")" +source ../buildScript/lib/core/get_source_env.sh +tool_root="$PWD/.build/toolchain" +tool_source="$tool_root/source" +mkdir -p "$tool_root/bin" +if [ ! -e "$tool_source" ]; then + git clone --no-checkout https://github.com/SagerNet/gomobile.git "$tool_source" + git -C "$tool_source" cat-file -e "$COMMIT_GOMOBILE^{commit}" 2>/dev/null || git -C "$tool_source" fetch origin "$COMMIT_GOMOBILE" + git -C "$tool_source" checkout --detach "$COMMIT_GOMOBILE" fi - -# Install gomobile -if [ ! -f "$GOPATH/bin/gomobile-matsuri" ]; then - git clone https://github.com/MatsuriDayo/gomobile.git - pushd gomobile - git checkout origin/master2 - pushd cmd - pushd gomobile - go install -v - popd - pushd gobind - go install -v - popd - popd - rm -rf gomobile - mv "$GOPATH/bin/gomobile" "$GOPATH/bin/gomobile-matsuri" - mv "$GOPATH/bin/gobind" "$GOPATH/bin/gobind-matsuri" -fi - -GOBIND=gobind-matsuri gomobile-matsuri init +[ "$(git -C "$tool_source" remote get-url origin)" = https://github.com/SagerNet/gomobile.git ] +[ "$(git -C "$tool_source" rev-parse HEAD)" = "$COMMIT_GOMOBILE" ] +[ "$(git -C "$tool_source" rev-parse "$VERSION_GOMOBILE^{commit}")" = "$COMMIT_GOMOBILE" ] +[ -z "$(git -C "$tool_source" status --porcelain --untracked-files=all)" ] +# Rebuild both actual binaries from the verified source; never reuse GOPATH tools. +# gomobile init would install an unpinned gobind and is unnecessary for binding. +pushd "$tool_source" +go build -mod=readonly -trimpath -buildvcs=true -o "$tool_root/bin/gomobile" ./cmd/gomobile +go build -mod=readonly -trimpath -buildvcs=true -o "$tool_root/bin/gobind" ./cmd/gobind +popd +go version -m "$tool_root/bin/gomobile" "$tool_root/bin/gobind" diff --git a/libcore/interface_monitor.go b/libcore/interface_monitor.go index 0167666e76..0ac72360e4 100644 --- a/libcore/interface_monitor.go +++ b/libcore/interface_monitor.go @@ -40,6 +40,6 @@ func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.Defa func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { } -func (s *interfaceMonitorStub) MyInterface() string { - return "" +func (s *interfaceMonitorStub) MyInterfaces() []string { + return nil } diff --git a/libcore/nb4a.go b/libcore/nb4a.go index cfa1e7824d..a24683b933 100644 --- a/libcore/nb4a.go +++ b/libcore/nb4a.go @@ -13,7 +13,6 @@ import ( "github.com/matsuridayo/libneko/neko_common" "github.com/matsuridayo/libneko/neko_log" - "github.com/sagernet/sing-box/nekoutils" "github.com/sagernet/sing-box/option" "golang.org/x/sys/unix" ) @@ -64,9 +63,6 @@ func InitCore(process, cachePath, internalAssets, externalAssets string, neko_log.TruncateOnStart = isBgProcess neko_log.SetupLog(int(maxLogSizeKb)*1024, filepath.Join(cachePath, "neko.log")) - // nekoutils - nekoutils.Selector_OnProxySelected = intfNB4A.Selector_OnProxySelected - // Set up some component go func() { defer device.DeferPanicToError("InitCore-go", func(err error) { log.Println(err) }) diff --git a/libcore/platform_box.go b/libcore/platform_box.go index 316cbef581..52e3a70f50 100644 --- a/libcore/platform_box.go +++ b/libcore/platform_box.go @@ -13,8 +13,6 @@ import ( "github.com/matsuridayo/libneko/neko_log" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/process" - "github.com/sagernet/sing-box/experimental/libbox/platform" sblog "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" tun "github.com/sagernet/sing-tun" @@ -23,11 +21,11 @@ import ( N "github.com/sagernet/sing/common/network" ) -var boxPlatformInterfaceInstance platform.Interface = &boxPlatformInterfaceWrapper{} +var boxPlatformInterfaceInstance adapter.PlatformInterface = &boxPlatformInterfaceWrapper{} type boxPlatformInterfaceWrapper struct{} -func (w *boxPlatformInterfaceWrapper) ReadWIFIState() adapter.WIFIState { +func (w *boxPlatformInterfaceWrapper) ReadWIFIState(ctx context.Context) adapter.WIFIState { state := strings.Split(intfBox.WIFIState(), ",") return adapter.WIFIState{ SSID: state[0], @@ -53,7 +51,7 @@ func (w *boxPlatformInterfaceWrapper) AutoDetectInterfaceControl(fd int) error { return intfBox.AutoDetectInterfaceControl(int32(fd)) } -func (w *boxPlatformInterfaceWrapper) OpenTun(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { +func (w *boxPlatformInterfaceWrapper) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { if len(options.IncludeUID) > 0 || len(options.ExcludeUID) > 0 { return nil, E.New("android: unsupported uid options") } @@ -88,19 +86,19 @@ func (w *boxPlatformInterfaceWrapper) CreateDefaultInterfaceMonitor(l logger.Log return &interfaceMonitorStub{} } -func (w *boxPlatformInterfaceWrapper) UsePlatformInterfaceGetter() bool { +func (w *boxPlatformInterfaceWrapper) UsePlatformNetworkInterfaces() bool { return false } -func (w *boxPlatformInterfaceWrapper) Interfaces() ([]adapter.NetworkInterface, error) { +func (w *boxPlatformInterfaceWrapper) NetworkInterfaces() ([]adapter.NetworkInterface, error) { return nil, errors.New("wtf") } -func (w *boxPlatformInterfaceWrapper) IncludeAllNetworks() bool { +func (w *boxPlatformInterfaceWrapper) NetworkExtensionIncludeAllNetworks() bool { return false } -func (w *boxPlatformInterfaceWrapper) SendNotification(notification *platform.Notification) error { +func (w *boxPlatformInterfaceWrapper) SendNotification(notification *adapter.Notification) error { return nil } @@ -117,9 +115,73 @@ func (w *boxPlatformInterfaceWrapper) UnderNetworkExtension() bool { func (w *boxPlatformInterfaceWrapper) ClearDNSCache() { } +func (w *boxPlatformInterfaceWrapper) UsePlatformInterface() bool { return true } +func (w *boxPlatformInterfaceWrapper) ProcessPlatformOptions(option.TunPlatformOptions) error { + return nil +} +func (w *boxPlatformInterfaceWrapper) RequestPermissionForWIFIState() error { return nil } +func (w *boxPlatformInterfaceWrapper) UsePlatformConnectionOwnerFinder() bool { return true } +func (w *boxPlatformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + var network string + switch request.IpProtocol { + case syscall.IPPROTO_TCP: + network = N.NetworkTCP + case syscall.IPPROTO_UDP: + network = N.NetworkUDP + default: + return nil, E.New("unknown IP protocol: ", request.IpProtocol) + } + source, err := netip.ParseAddr(request.SourceAddress) + if err != nil { + return nil, err + } + destination, err := netip.ParseAddr(request.DestinationAddress) + if err != nil { + return nil, err + } + info, err := w.FindProcessInfo(context.Background(), network, netip.AddrPortFrom(source, uint16(request.SourcePort)), netip.AddrPortFrom(destination, uint16(request.DestinationPort))) + if err != nil { + return nil, err + } + return info, nil +} + +func (w *boxPlatformInterfaceWrapper) UsePlatformWIFIMonitor() bool { return true } + +// These optional capabilities were never provided by the App's Java bridge. +func (w *boxPlatformInterfaceWrapper) UsePlatformNotification() bool { return false } +func (w *boxPlatformInterfaceWrapper) CancelNotification(string, int32) error { return nil } +func (w *boxPlatformInterfaceWrapper) MyInterfaceAddress() []netip.Addr { return nil } +func (w *boxPlatformInterfaceWrapper) UsePlatformNeighborResolver() bool { return false } +func (w *boxPlatformInterfaceWrapper) StartNeighborMonitor(adapter.NeighborUpdateListener) error { + return errors.ErrUnsupported +} +func (w *boxPlatformInterfaceWrapper) CloseNeighborMonitor(adapter.NeighborUpdateListener) error { + return nil +} +func (w *boxPlatformInterfaceWrapper) UsePlatformShell() bool { return false } +func (w *boxPlatformInterfaceWrapper) CheckPlatformShell() error { return errors.ErrUnsupported } +func (w *boxPlatformInterfaceWrapper) OpenShellSession(*adapter.PlatformUser, string, []string, string, int32, int32) (adapter.ShellSession, error) { + return nil, errors.ErrUnsupported +} +func (w *boxPlatformInterfaceWrapper) LookupUser(string) (*adapter.PlatformUser, error) { + return nil, errors.ErrUnsupported +} +func (w *boxPlatformInterfaceWrapper) LookupSFTPServer() (string, error) { + return "", errors.ErrUnsupported +} +func (w *boxPlatformInterfaceWrapper) ReadSystemSSHHostKey() ([]byte, error) { + return nil, errors.ErrUnsupported +} +func (w *boxPlatformInterfaceWrapper) TailscaleHostname() string { return "" } +func (w *boxPlatformInterfaceWrapper) UsePlatformBridge() bool { return false } +func (w *boxPlatformInterfaceWrapper) CreateBridge(adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, errors.ErrUnsupported +} + // process.Searcher -func (w *boxPlatformInterfaceWrapper) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*process.Info, error) { +func (w *boxPlatformInterfaceWrapper) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { var uid int32 if useProcfs { uid = procfs.ResolveSocketByProcSearch(network, source, destination) @@ -143,7 +205,7 @@ func (w *boxPlatformInterfaceWrapper) FindProcessInfo(ctx context.Context, netwo } } packageName, _ := intfBox.PackageNameByUid(uid) - return &process.Info{UserId: uid, PackageName: packageName}, nil + return &adapter.ConnectionOwner{UserId: uid, AndroidPackageNames: []string{packageName}}, nil } // io.Writer diff --git a/libcore/sniff_compat.go b/libcore/sniff_compat.go new file mode 100644 index 0000000000..d84ad663c2 --- /dev/null +++ b/libcore/sniff_compat.go @@ -0,0 +1,79 @@ +package libcore + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/route/rule" +) + +// v1.14 retains the public runtime override flag but no JSON field for it. +// Consume only the explicit App marker; all other custom JSON stays subject +// to the official decoder without legacy-field rewriting. +func sniffCompatibility(config string) (string, []int, error) { + if !strings.Contains(config, "_neko_sniff_override") { + return config, nil, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal([]byte(config), &root); err != nil { + return "", nil, err + } + var route map[string]json.RawMessage + if err := json.Unmarshal(root["route"], &route); err != nil { + return "", nil, err + } + var rules []map[string]json.RawMessage + if err := json.Unmarshal(route["rules"], &rules); err != nil { + return "", nil, err + } + var indices []int + for index, item := range rules { + marker, exists := item["_neko_sniff_override"] + if !exists { + continue + } + var enabled bool + if err := json.Unmarshal(marker, &enabled); err != nil { + return "", nil, err + } + var action string + if err := json.Unmarshal(item["action"], &action); err != nil { + return "", nil, err + } + if action != "sniff" { + return "", nil, fmt.Errorf("route rule %d: sniff override requires a sniff action", index) + } + delete(item, "_neko_sniff_override") + if enabled { + indices = append(indices, index) + } + } + var err error + route["rules"], err = json.Marshal(rules) + if err != nil { + return "", nil, err + } + root["route"], err = json.Marshal(route) + if err != nil { + return "", nil, err + } + data, err := json.Marshal(root) + return string(data), indices, err +} + +func applySniffCompatibility(router adapter.Router, indices []int) error { + rules := router.Rules() + for _, index := range indices { + if index >= len(rules) { + return fmt.Errorf("sniff rule %d missing", index) + } + action, ok := rules[index].Action().(*rule.RuleActionSniff) + if !ok { + return fmt.Errorf("route rule %d is not a sniff action", index) + } + action.OverrideDestination = true + } + return nil +} diff --git a/nb4a.properties b/nb4a.properties index b2433289aa..0b2ca8949f 100644 --- a/nb4a.properties +++ b/nb4a.properties @@ -1,4 +1,4 @@ PACKAGE_NAME=moe.nb4a -VERSION_NAME=1.4.2 +VERSION_NAME=1.4.3 PRE_VERSION_NAME=pre-1.4.2-20260202-1 -VERSION_CODE=46 +VERSION_CODE=47 diff --git a/run b/run index 917e9daa45..b69a98cc20 100755 --- a/run +++ b/run @@ -2,20 +2,20 @@ EXEC="" TARGET="buildScript" -declare -A PARAMS -declare -i INDEX=0 +PARAMS=() -for e in $@; do +for e in "$@"; do TARGET="$TARGET/$e" - PARAMS[$INDEX]=$e - INDEX=$INDEX+1 - shift + PARAMS+=("$e") if [ -x "${TARGET}.sh" ]; then EXEC="${TARGET}.sh" PARAMS=() - INDEX=0 fi done +if [ -z "$EXEC" ]; then + echo "Usage: $0 [arguments...]" >&2 + exit 2 +fi echo ">> $EXEC" -exec "$EXEC" $PARAMS +exec "$EXEC" "${PARAMS[@]}"