owenclave / app/src/main/java/io/nekohasekai/sagernet/bg/proto/V2RayInstance.kt

github - dev - back to tree - upstream
/****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * ******************************************************************************/ package io.nekohasekai.sagernet.bg.proto import android.annotation.SuppressLint import android.os.Looper import android.os.SystemClock import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import io.nekohasekai.sagernet.RootCAProvider import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.V2rayBuildResult import io.nekohasekai.sagernet.fmt.buildV2RayConfig import io.nekohasekai.sagernet.fmt.naive.NaiveBean import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig import io.nekohasekai.sagernet.fmt.shadowquic.ShadowQUICBean import io.nekohasekai.sagernet.fmt.shadowquic.buildShadowQUICConfig import io.nekohasekai.sagernet.fmt.olcrtc.OLCRTCBean import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import kotlinx.coroutines.* import libexclavecore.V2RayInstance import java.io.File import java.net.InetSocketAddress import java.net.Socket abstract class V2RayInstance( val profile: ProxyEntity, ) : AbstractInstance { companion object { // How long to keep the connecting phase while waiting for an // external engine's local SOCKS port to come up. private const val READINESS_TIMEOUT_MS = 15_000L private const val READINESS_POLL_INTERVAL_MS = 250L private const val READINESS_CONNECT_TIMEOUT_MS = 500 } protected fun buildOlcrtcYaml(bean: OLCRTCBean, port: Int, username: String, password: String): String { // olcrtc resolves a relative `data` dir against the executable dir // (read-only nativeLibraryDir on Android), so use an absolute writable path. val dataDir = File(SagerNet.application.noBackupFilesDir, "olcrtc_data").apply { mkdirs() } return buildString { appendLine("mode: cnc") appendLine("auth:") appendLine(" provider: ${bean.authProvider}") appendLine("room:") appendLine(" id: \"${bean.roomId}\"") appendLine("crypto:") appendLine(" key: \"${bean.encryptionKey}\"") appendLine("net:") appendLine(" transport: ${bean.transport}") appendLine(" dns: \"${bean.dnsServer}\"") appendLine("socks:") appendLine(" host: \"127.0.0.1\"") appendLine(" port: $port") if (username.isNotEmpty()) appendLine(" user: \"$username\"") if (password.isNotEmpty()) appendLine(" pass: \"$password\"") appendLine("data: \"${dataDir.absolutePath}\"") appendLine("debug: true") }.also { Logs.d("olcrtc yaml config for port $port:\n$it") } } lateinit var config: V2rayBuildResult lateinit var v2rayPoint: V2RayInstance private lateinit var wsForwarder: WebView private lateinit var shForwarder: WebView val pluginPath = hashMapOf() val pluginConfigs = hashMapOf>() val externalInstances = hashMapOf() // Local SOCKS ports of external engines that need to finish bringing up // their transport before they can pass traffic (e.g. olcrtc WebRTC). // awaitReady() waits for these to accept connections. private val readinessPorts = mutableListOf() open lateinit var processes: GuardedProcessPool private var cacheFiles = ArrayList() fun isInitialized(): Boolean { return ::config.isInitialized } protected fun initPlugin(name: String): PluginManager.InitResult { return pluginPath.getOrPut(name) { PluginManager.init(name)!! } } protected open fun buildConfig() { config = buildV2RayConfig(profile) } protected open fun loadConfig() { v2rayPoint.loadConfig(config.config) } open suspend fun init() { v2rayPoint = V2RayInstance() buildConfig() for ((_, chain) in config.index) { chain.entries.forEachIndexed { _, (triple, profile) -> val port = triple.first val username = triple.second val password = triple.third when (val bean = profile.requireBean()) { is NaiveBean -> { initPlugin("naive-plugin") pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port, username, password) } is ShadowQUICBean -> { initPlugin("shadowquic-plugin") pluginConfigs[port] = profile.type to bean.buildShadowQUICConfig( port, username, password, { File(app.noBackupFilesDir, "shadowquic_" + SystemClock.elapsedRealtime() + ".pem").apply { parentFile?.mkdirs() cacheFiles.add(this) } } ) } is OLCRTCBean -> { val yamlConfig = buildOlcrtcYaml(bean, port, username, password) pluginConfigs[port] = profile.type to yamlConfig } } } } loadConfig() } @SuppressLint("SetJavaScriptEnabled") override fun launch() { val context = SagerNet.application for ((_, chain) in config.index) { chain.entries.forEachIndexed { _, (triple, profile) -> val port = triple.first val bean = profile.requireBean() val (_, config) = pluginConfigs[port] ?: (0 to "") val env = mutableMapOf() if (DataStore.providerRootCA != RootCAProvider.SYSTEM) { env["SSL_CERT_FILE"] = when (DataStore.providerRootCA) { RootCAProvider.MOZILLA -> { File(app.filesDir, "mozilla_included.pem").canonicalPath } RootCAProvider.SYSTEM_AND_USER -> { File(app.filesDir, "android_included.pem").canonicalPath } RootCAProvider.CUSTOM -> { File(app.externalAssets, "root_store.certs").canonicalPath } else -> error("impossible") } } when { externalInstances.containsKey(port) -> { externalInstances[port]!!.launch() } bean is NaiveBean -> { val configFile = File( context.noBackupFilesDir, "naive_" + SystemClock.elapsedRealtime() + ".json" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) if (bean.certificate.isNotEmpty()) { val caFile = File( context.noBackupFilesDir, "naive_" + SystemClock.elapsedRealtime() + ".ca" ) caFile.parentFile?.mkdirs() caFile.writeText(bean.certificate) cacheFiles.add(caFile) env["SSL_CERT_FILE"] = caFile.absolutePath } val commands = mutableListOf( initPlugin("naive-plugin").path, configFile.absolutePath ) processes.start(commands, env) } bean is ShadowQUICBean -> { val configFile = File( context.noBackupFilesDir, "shadowquic_" + SystemClock.elapsedRealtime() + ".yaml" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) if (DataStore.providerRootCA == RootCAProvider.SYSTEM) { // https://github.com/rustls/rustls-native-certs/issues/3 env["SSL_CERT_DIR"] = "/system/etc/security/cacerts" } val commands = mutableListOf( initPlugin("shadowquic-plugin").path, "-c", configFile.absolutePath, ) processes.start(commands, env) } bean is OLCRTCBean -> { val configFile = File( context.noBackupFilesDir, "olcrtc_" + SystemClock.elapsedRealtime() + ".yaml" ) configFile.parentFile?.mkdirs() configFile.writeText(config) cacheFiles.add(configFile) val olcrtcBin = File(context.applicationInfo.nativeLibraryDir, "libolcrtc.so") Logs.i("olcrtc: nativeLibraryDir=${context.applicationInfo.nativeLibraryDir}") Logs.i("olcrtc: binary=${olcrtcBin.absolutePath} exists=${olcrtcBin.exists()} canExecute=${olcrtcBin.canExecute()} size=${if (olcrtcBin.exists()) olcrtcBin.length() else -1}") Logs.i("olcrtc: config file=${configFile.absolutePath} exists=${configFile.exists()}") Logs.d("olcrtc: config content:\n$config") if (!olcrtcBin.exists()) { Logs.e("olcrtc: binary libolcrtc.so NOT FOUND in nativeLibraryDir. " + "Check that jniLibs//libolcrtc.so is packaged and extractNativeLibs/useLegacyPackaging is enabled.") } val commands = mutableListOf( olcrtcBin.absolutePath, configFile.absolutePath ) Logs.i("olcrtc: launching: ${commands.joinToString(" ")}") processes.start(commands, env) // olcrtc needs to negotiate WebRTC before its local // SOCKS listener is usable; awaitReady() waits on it. readinessPorts.add(port) } } } } v2rayPoint.start() if (config.requireWs) { val url = "http://" + joinHostPort(LOCALHOST, config.wsPort) + "/" runOnMainDispatcher { wsForwarder = WebView(context) wsForwarder.settings.javaScriptEnabled = true wsForwarder.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError?, ) { Logs.d("WebView load r: $error") runOnMainDispatcher { wsForwarder.loadUrl("about:blank") delay(1000L) wsForwarder.loadUrl(url) } } override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) Logs.d("WebView loaded: ${view.title}") } } wsForwarder.loadUrl(url) } } if (config.requireSh) { val url = "http://" + joinHostPort(LOCALHOST, config.shPort) + "/" runOnMainDispatcher { shForwarder = WebView(context) shForwarder.settings.javaScriptEnabled = true shForwarder.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError?, ) { Logs.d("WebView load r: $error") runOnMainDispatcher { shForwarder.loadUrl("about:blank") delay(1000L) shForwarder.loadUrl(url) } } override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) Logs.d("WebView loaded: ${view.title}") } } shForwarder.loadUrl(url) } } } /** * Wait until every external engine's local SOCKS port accepts a TCP * connection, so the service stays in the Connecting state until traffic * can really flow (olcrtc must finish WebRTC negotiation first). * * Times out after [READINESS_TIMEOUT_MS] and returns anyway, so a stuck * engine still surfaces to the user as connected-but-failing rather than * hanging the connect flow forever. */ override suspend fun awaitReady() { if (readinessPorts.isEmpty()) return withContext(Dispatchers.IO) { for (port in readinessPorts) { val deadline = SystemClock.elapsedRealtime() + READINESS_TIMEOUT_MS Logs.i("readiness: waiting for local SOCKS 127.0.0.1:$port to come up (timeout ${READINESS_TIMEOUT_MS}ms)") var ready = false while (SystemClock.elapsedRealtime() < deadline) { if (!isActive) return@withContext try { Socket().use { sock -> sock.connect(InetSocketAddress(LOCALHOST, port), READINESS_CONNECT_TIMEOUT_MS) } ready = true break } catch (_: Exception) { delay(READINESS_POLL_INTERVAL_MS) } } if (ready) { Logs.i("readiness: local SOCKS 127.0.0.1:$port is up") } else { Logs.w("readiness: local SOCKS 127.0.0.1:$port did not come up within ${READINESS_TIMEOUT_MS}ms; continuing anyway") } } } } private var isClosed = false @Suppress("EXPERIMENTAL_API_USAGE") override fun close() { if (isClosed) return for (instance in externalInstances.values) { runCatching { instance.close() } } cacheFiles.removeAll { it.delete(); true } if (::wsForwarder.isInitialized) { if (Thread.currentThread() === Looper.getMainLooper().thread) { wsForwarder.loadUrl("about:blank") wsForwarder.destroy() } else { runBlocking { onMainDispatcher { wsForwarder.loadUrl("about:blank") wsForwarder.destroy() } } } } if (::shForwarder.isInitialized) { if (Thread.currentThread() === Looper.getMainLooper().thread) { shForwarder.loadUrl("about:blank") shForwarder.destroy() } else { runBlocking { onMainDispatcher { shForwarder.loadUrl("about:blank") shForwarder.destroy() } } } } if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO) if (::v2rayPoint.isInitialized) { v2rayPoint.close() } isClosed = true } }