diff --git a/.gitignore b/.gitignore index 9cc17787f..3debf396c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ local.properties # Submodules / external sources /external/ + +# Signing keys — never commit; provided via CI secrets or local.properties +release.keystore +*.keystore +*.jks diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bda0d6c62..15bfb7d48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,7 +74,7 @@ diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 62abe81dc..44ad8071f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -47,6 +47,8 @@ object Key { const val MIXED_SECRET = "mixedSecret" // storage key for the generated inbound secret const val MIXED_USERNAME = "neko" // username presented to the authed mixed inbound const val ALLOW_ACCESS = "allowAccess" + const val REQUIRE_PROXY_IN_VPN = "requireProxyInVPN" // keep local mixed inbound open in VPN mode + const val PROXY_MODE_INBOUND_AUTH = "proxyModeInboundAuth" // authenticate loopback inbound in Proxy mode const val SPEED_INTERVAL = "speedInterval" const val SHOW_DIRECT_SPEED = "showDirectSpeed" diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index ebac2397e..38b72f84f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -169,7 +169,19 @@ object DataStore : OnPreferenceDataStoreChangeListener { // inbound is authenticated, except when appendHttpProxy is active on // Android Q+ (the system HTTP proxy cannot supply credentials). (serviceMode == Key.MODE_VPN && - !(appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)) + !(appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)) || + // Proxy service mode loopback inbound: optionally authenticate too + // (issue #1197 residual gap). Off by default to preserve the + // "open localhost proxy" use case; users opt in for hardening. + (serviceMode == Key.MODE_PROXY && proxyModeInboundAuth) + + // Keep the local mixed (SOCKS/HTTP) inbound open in VPN mode. Default false: in + // TUN mode the local proxy port is usually unnecessary, and closing it removes the + // local port-scan attack surface entirely (PR #1154 / issue #1197). + var requireProxyInVPN by configurationStore.boolean(Key.REQUIRE_PROXY_IN_VPN) + + // Authenticate the loopback mixed inbound in Proxy service mode (issue #1197). + var proxyModeInboundAuth by configurationStore.boolean(Key.PROXY_MODE_INBOUND_AUTH) val mixedInboundUser: String get() = if (mixedInboundAuthed) Key.MIXED_USERNAME else "" val mixedInboundPass: String get() = if (mixedInboundAuthed) mixedSecret else "" 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 a2b49e313..331e5daee 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.fmt +import android.os.Build import android.widget.Toast import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.bg.VpnService @@ -147,6 +148,14 @@ fun buildConfig( val bypassDNSBeans = hashSetOf() val isVPN = DataStore.serviceMode == Key.MODE_VPN val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST + // Whether the local mixed (SOCKS/HTTP) inbound is present in the final config. + // In VPN/TUN mode it is omitted unless the user opts in (requireProxyInVPN) or the + // system HTTP proxy needs it (appendHttpProxy). See issue #1197 / PR #1154. + val keepMixedInbound = !forTest && ( + !isVPN || + DataStore.requireProxyInVPN || + (DataStore.appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ) val remoteDns = DataStore.remoteDns.split("\n") .mapNotNull { dns -> sanitizeDnsEntry(dns).takeIf { it.isNotBlank() && !it.startsWith("#") } } val directDNS = DataStore.directDns.split("\n") @@ -252,7 +261,11 @@ fun buildConfig( } } }) - inbounds.add(Inbound_MixedOptions().apply { + // Local mixed (SOCKS/HTTP) inbound. In VPN/TUN mode this port is usually + // unnecessary and only widens the local attack surface (issue #1197), so it + // is omitted unless the user opts in (requireProxyInVPN) or the system HTTP + // proxy needs it (appendHttpProxy routes the device proxy through this port). + if (keepMixedInbound) inbounds.add(Inbound_MixedOptions().apply { type = "mixed" tag = TAG_MIXED listen = bind @@ -570,7 +583,7 @@ fun buildConfig( outbound = mainProxyTag }) - route.rules.add(Rule_DefaultOptions().apply { + if (keepMixedInbound) route.rules.add(Rule_DefaultOptions().apply { inbound = listOf(TAG_MIXED) outbound = mainProxyTag }) 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 1e8aeb3f8..08e39d87c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -267,6 +267,13 @@ object RawUpdater : GroupUpdater() { // tag; a YAML mapping is returned as a LinkedHashMap natively. val loaderOptions = LoaderOptions().apply { codePointLimit = 10 * 1024 * 1024 // 10 MiB + // SnakeYAML 2.x defaults maxAliasesForCollections to 50 as a + // billion-laughs guard. Legitimate large Clash/Mihomo configs + // reuse anchors heavily and exceed 50 (issue #1042). Raise to a + // finite cap (not Int.MAX_VALUE). 200 covers known real-world + // configs while keeping alias-expansion amplification bounded + // (codePointLimit bounds input size, not the expanded object graph). + maxAliasesForCollections = 200 } // In SnakeYAML 2.x, Yaml(BaseConstructor) adopts the constructor's // LoaderOptions (getLoadingConfig()), so codePointLimit set above is diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt index 2b8ccffb6..ab7db2549 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -113,6 +113,11 @@ suspend fun parseProxies(text: String): List { val entities = ArrayList() val entitiesByLine = ArrayList() + // An http(s) link that fails to parse as an HTTP proxy is a subscription candidate. + // Don't abort import immediately (issue #1128): a file may contain valid profile + // links alongside a plain promo/Telegram URL. Remember the first candidate and only + // treat the input as a subscription if NO profiles parsed at all. + var subscriptionCandidate: String? = null fun String.parseLink(entities: ArrayList) { if (startsWith("clash://install-config?") || startsWith("sn://subscription?")) { @@ -142,14 +147,17 @@ suspend fun parseProxies(text: String): List { entities.add(parseHttp(this)) }.onFailure { Logs.w(it) - val clashUrl = HttpUrl.Builder() - .scheme("https") - .host("install-config") - .addQueryParameter("url", this) - .build() - .toString() - .replaceFirst("https://", "clash://") - throw (SubscriptionFoundException(clashUrl)) + if (subscriptionCandidate == null) { + val clashUrl = HttpUrl.Builder() + .scheme("https") + .host("install-config") + .addQueryParameter("url", this) + .build() + .toString() + .replaceFirst("https://", "clash://") + // Defer: only thrown later if no profile links were parsed. + subscriptionCandidate = clashUrl + } } } else if (startsWith("vmess://")) { Logs.d("Try parse v2ray link: $this") @@ -258,6 +266,12 @@ suspend fun parseProxies(text: String): List { for (link in linksByLine) { link.parseLink(entitiesByLine) } + // No profile links parsed but we saw an unparsable http(s) URL: treat the whole + // input as a subscription link (single-URL paste / file). When profiles WERE found, + // the stray URL is ignored so the profiles still import (issue #1128). + if (entities.isEmpty() && entitiesByLine.isEmpty()) { + subscriptionCandidate?.let { throw SubscriptionFoundException(it) } + } // var isBadLink = false if (entities.onEach { it.initializeDefaultValues() }.size == entitiesByLine.onEach { it.initializeDefaultValues() }.size) run test@{ entities.forEachIndexed { index, bean -> diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt index 75a64a82a..597d23b1e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt @@ -104,7 +104,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_layers_24) - .text(getString(R.string.version_x, "sing-box")) + .text(activityContext.getString(R.string.version_x, "sing-box")) .subText(Libcore.versionBox()) .setOnClickAction { } .build()) @@ -130,7 +130,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_nfc_24) .text( - getString( + activityContext.getString( R.string.version_x, pluginId ) + " (${Plugins.displayExeProvider(pkg.packageName)})" @@ -248,6 +248,11 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { haveUpdate && !releaseName.contains(BuildConfig.VERSION_NAME) } runOnMainDispatcher { + // The async work above may outlive the fragment's attachment + // (e.g. user navigates away). Touching requireContext()/app + // resources while detached throws IllegalStateException + // (issue #1192). Bail out if no longer attached. + if (!isAdded) return@runOnMainDispatcher if (haveUpdate) { val context = requireContext() MaterialAlertDialogBuilder(context) @@ -272,6 +277,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { } catch (e: Exception) { Logs.w(e) runOnMainDispatcher { + if (!isAdded) return@runOnMainDispatcher Toast.makeText(app, e.readableMessage, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt index effbc5823..4b3c6f897 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt @@ -13,8 +13,6 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.Filter -import android.widget.Filterable import androidx.annotation.UiThread import androidx.core.util.contains import androidx.core.util.set @@ -42,7 +40,9 @@ import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.ListListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import moe.matsuri.nb4a.utils.NGUtil import kotlin.coroutines.coroutineContext @@ -101,7 +101,6 @@ class AppManagerActivity : ThemedActivity() { } private inner class AppsAdapter : RecyclerView.Adapter(), - Filterable, FastScrollRecyclerView.SectionedAdapter { var filteredApps = apps @@ -130,26 +129,25 @@ class AppManagerActivity : ThemedActivity() { override fun getItemCount(): Int = filteredApps.size - private val filterImpl = object : Filter() { - override fun performFiltering(constraint: CharSequence) = FilterResults().apply { - var filteredApps = if (constraint.isEmpty()) apps else apps.filter { - it.name.contains(constraint, true) || it.packageName.contains( - constraint, true - ) || it.uid.toString().contains(constraint) - } - if (!sysApps) filteredApps = filteredApps.filter { !it.sys } - count = filteredApps.size - values = filteredApps - } - - override fun publishResults(constraint: CharSequence, results: FilterResults) { - @Suppress("UNCHECKED_CAST") - filteredApps = results.values as List - notifyDataSetChanged() + // Replaces android.widget.Filter, which spawned a new worker thread per + // filter() call (OutOfMemoryError: pthread_create failed, issue #1086). + // Computes the filtered list off the main thread and publishes on main; + // callers cancel the previous job via applyFilter() below. + fun computeFiltered(constraint: CharSequence): List { + var result = if (constraint.isEmpty()) apps else apps.filter { + it.name.contains(constraint, true) || it.packageName.contains( + constraint, true + ) || it.uid.toString().contains(constraint) } + if (!sysApps) result = result.filter { !it.sys } + return result } - override fun getFilter(): Filter = filterImpl + fun publishFiltered(result: List) { + filteredApps = result + @Suppress("NotifyDataSetChanged") + notifyDataSetChanged() + } override fun getSectionName(position: Int): String { return filteredApps[position].name.firstOrNull()?.toString() ?: "" @@ -162,9 +160,27 @@ class AppManagerActivity : ThemedActivity() { private lateinit var binding: LayoutAppsBinding private val proxiedUids = SparseBooleanArray() private var loader: Job? = null + private var filterJob: Job? = null + // Read on Dispatchers.Default in computeFiltered(); written from Default/IO/Main. + // @Volatile guarantees cross-thread visibility so filtering never sees a stale list. + @Volatile private var apps = emptyList() private val appsAdapter = AppsAdapter() + // Coroutine-based replacement for the old Filter.filter(...) calls. Cancels the + // in-flight filter job (so rapid keystrokes don't pile up work) and computes the + // filtered list on a background dispatcher before publishing on main. + // debounceMs > 0 is used for the search box to avoid filtering on every keystroke. + @UiThread + private fun applyFilter(constraint: String = binding.search.text?.toString() ?: "", debounceMs: Long = 0) { + filterJob?.cancel() + filterJob = lifecycleScope.launch { + if (debounceMs > 0) delay(debounceMs) + val result = withContext(Dispatchers.Default) { appsAdapter.computeFiltered(constraint) } + appsAdapter.publishFiltered(result) + } + } + private fun initProxiedUids(str: String = DataStore.individual) { proxiedUids.clear() val apps = cachedApps @@ -184,7 +200,7 @@ class AppManagerActivity : ThemedActivity() { loading.crossFadeFrom(binding.list) val adapter = binding.list.adapter as AppsAdapter withContext(Dispatchers.IO) { adapter.reload() } - adapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() if (apps.isEmpty()) { binding.list.visibility = View.GONE binding.appPlaceholder.root.crossFadeFrom(loading) @@ -241,19 +257,20 @@ class AppManagerActivity : ThemedActivity() { ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) binding.search.addTextChangedListener { - appsAdapter.filter.filter(it?.toString() ?: "") + applyFilter(it?.toString() ?: "", debounceMs = 250) } binding.showSystemApps.isChecked = sysApps binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> sysApps = isChecked - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } instance = this loadApps() } + @Volatile private var sysApps = true override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -277,7 +294,7 @@ class AppManagerActivity : ThemedActivity() { .joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } } @@ -290,7 +307,7 @@ class AppManagerActivity : ThemedActivity() { DataStore.individual = "" apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } } } @@ -363,7 +380,7 @@ class AppManagerActivity : ThemedActivity() { DataStore.individual = apps.filter { isProxiedApp(it) }.joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } catch (e: Exception) { Logs.e(e) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 1bff1dcc2..bc7ad1d56 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -914,7 +914,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - dialog.dismiss() + try { dialog.dismiss() } catch (e: IllegalStateException) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } @@ -983,7 +983,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - dialog.dismiss() + try { dialog.dismiss() } catch (e: IllegalStateException) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 950898699..7be22d054 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,10 @@ Route Settings Allow Connections from the LAN Bind inbound servers to 0.0.0.0 + Keep local proxy in VPN mode + Keep the local SOCKS/HTTP proxy port open while in VPN mode. Off by default: in VPN mode the port is usually unneeded and leaving it open lets other apps reach it. + Authenticate local proxy in Proxy mode + Require username/password on the local SOCKS/HTTP proxy when running in Proxy (non-VPN) mode. Enable to stop other apps from using it as an open relay. Inbound Settings App Settings Enable HTTP inbound diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 72e7d4de0..bd9d9e463 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -261,6 +261,16 @@ app:key="allowAccess" app:summary="@string/allow_access_sum" app:title="@string/allow_access" /> + + diff --git a/release.keystore b/release.keystore deleted file mode 100644 index 7b58d27c8..000000000 Binary files a/release.keystore and /dev/null differ