Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@

<activity
android:name="io.nekohasekai.sagernet.ui.MainActivity"
android:configChanges="uiMode"
android:configChanges="uiMode|orientation|screenSize|keyboardHidden"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
17 changes: 15 additions & 2 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -147,6 +148,14 @@ fun buildConfig(
val bypassDNSBeans = hashSetOf<AbstractBean>()
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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
})
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// LoaderOptions (getLoadingConfig()), so codePointLimit set above is
Expand Down
30 changes: 22 additions & 8 deletions app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ suspend fun parseProxies(text: String): List<AbstractBean> {

val entities = ArrayList<AbstractBean>()
val entitiesByLine = ArrayList<AbstractBean>()
// 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<AbstractBean>) {
if (startsWith("clash://install-config?") || startsWith("sn://subscription?")) {
Expand Down Expand Up @@ -142,14 +147,17 @@ suspend fun parseProxies(text: String): List<AbstractBean> {
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")
Expand Down Expand Up @@ -258,6 +266,12 @@ suspend fun parseProxies(text: String): List<AbstractBean> {
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 ->
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)})"
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
}
Expand Down
69 changes: 43 additions & 26 deletions app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -101,7 +101,6 @@ class AppManagerActivity : ThemedActivity() {
}

private inner class AppsAdapter : RecyclerView.Adapter<AppViewHolder>(),
Filterable,
FastScrollRecyclerView.SectionedAdapter {
var filteredApps = apps

Expand Down Expand Up @@ -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<ProxiedApp>
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<ProxiedApp> {
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<ProxiedApp>) {
filteredApps = result
@Suppress("NotifyDataSetChanged")
notifyDataSetChanged()
}

override fun getSectionName(position: Int): String {
return filteredApps[position].name.firstOrNull()?.toString() ?: ""
Expand All @@ -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<ProxiedApp>()
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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
}

Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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() }
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
<string name="cag_route">Route Settings</string>
<string name="allow_access">Allow Connections from the LAN</string>
<string name="allow_access_sum">Bind inbound servers to 0.0.0.0</string>
<string name="require_proxy_in_vpn">Keep local proxy in VPN mode</string>
<string name="require_proxy_in_vpn_sum">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.</string>
<string name="proxy_mode_inbound_auth">Authenticate local proxy in Proxy mode</string>
<string name="proxy_mode_inbound_auth_sum">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.</string>
<string name="inbound_settings">Inbound Settings</string>
<string name="general_settings">App Settings</string>
<string name="require_http">Enable HTTP inbound</string>
Expand Down
Loading
Loading