Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.weblibre.flutter_singbox_proxy">
|
||||
</manifest>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyEventsApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
|
||||
/** Flutter plugin entry point for the sing-box proxy runtime. */
|
||||
class FlutterSingboxProxyPlugin : FlutterPlugin {
|
||||
private var runtimeManager: SingboxRuntimeManager? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
val eventsApi = SingboxProxyEventsApi(binding.binaryMessenger)
|
||||
val manager = SingboxRuntimeManager(
|
||||
context = binding.applicationContext,
|
||||
onStateChanged = { state: SingboxProxyRuntimeState ->
|
||||
eventsApi.onStateChanged(state) { }
|
||||
},
|
||||
onLogMessage = { message: SingboxProxyLogMessage ->
|
||||
eventsApi.onLogMessage(message) { }
|
||||
}
|
||||
)
|
||||
runtimeManager = manager
|
||||
SingboxProxyApi.setUp(binding.binaryMessenger, manager)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
SingboxProxyApi.setUp(binding.binaryMessenger, null)
|
||||
runtimeManager?.close()
|
||||
runtimeManager = null
|
||||
}
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
open class LibboxRuntime(
|
||||
private val context: Context,
|
||||
private val dohResolver: PlatformDohResolver = PlatformDohResolver(),
|
||||
) {
|
||||
private var setupComplete = false
|
||||
private var commandServer: Any? = null
|
||||
private var commandServerStarted = false
|
||||
private var commandClient: Any? = null
|
||||
private var logSink: ((Int, String) -> Unit)? = null
|
||||
private var logClientThread: Thread? = null
|
||||
|
||||
// Read by the LocalDNSTransport proxy on libbox worker threads, written
|
||||
// from the platform thread via setBootstrapDohUrl(). Volatile so the
|
||||
// bridge sees the URL configured by the most recent start() call.
|
||||
@Volatile
|
||||
private var bootstrapDohUrl: String? = null
|
||||
|
||||
/**
|
||||
* Set the DoH endpoint the platform LocalDNSTransport will use for
|
||||
* bootstrap lookups. Pass null to disable the bridge (sing-box's broken
|
||||
* /etc/resolv.conf path will run, which is rarely what you want on
|
||||
* Android).
|
||||
*/
|
||||
open fun setBootstrapDohUrl(url: String?) {
|
||||
bootstrapDohUrl = url?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
open fun isAvailable(): Boolean = runCatching {
|
||||
Class.forName(LIBBOX_CLASS)
|
||||
}.isSuccess
|
||||
|
||||
/**
|
||||
* Register a callback that receives every log message emitted by sing-box
|
||||
* (level: int, message: String). Pass null to clear. The callback is
|
||||
* invoked from a background thread; the receiver must be thread-safe.
|
||||
*/
|
||||
@Synchronized
|
||||
open fun setLogSink(sink: ((Int, String) -> Unit)?) {
|
||||
logSink = sink
|
||||
if (sink == null) {
|
||||
disconnectLogClient()
|
||||
} else if (commandServer != null) {
|
||||
ensureLogClientConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun start(configJson: String) {
|
||||
ensureSetup()
|
||||
val server = commandServer ?: newCommandServer().also { commandServer = it }
|
||||
ensureCommandServerStarted(server)
|
||||
val overrideOptions = newInstance(OVERRIDE_OPTIONS_CLASS)
|
||||
invoke(overrideOptions, "setAutoRedirect", false)
|
||||
invoke(overrideOptions, "setIncludePackage", emptyStringIterator())
|
||||
invoke(overrideOptions, "setExcludePackage", emptyStringIterator())
|
||||
invoke(server, "startOrReloadService", configJson, overrideOptions)
|
||||
if (logSink != null) {
|
||||
ensureLogClientConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun stopService() {
|
||||
commandServer?.let { server ->
|
||||
runCatching { invoke(server, "closeService") }
|
||||
}
|
||||
disconnectLogClient()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
open fun close() {
|
||||
disconnectLogClient()
|
||||
commandServer?.let { server ->
|
||||
runCatching { invoke(server, "close") }
|
||||
}
|
||||
commandServer = null
|
||||
commandServerStarted = false
|
||||
}
|
||||
|
||||
private fun ensureSetup() {
|
||||
if (setupComplete) return
|
||||
val baseDir = context.filesDir.resolve("singbox_proxy")
|
||||
val workingDir = baseDir.resolve("working")
|
||||
val tempDir = baseDir.resolve("tmp")
|
||||
workingDir.mkdirs()
|
||||
tempDir.mkdirs()
|
||||
|
||||
val options = newInstance(SETUP_OPTIONS_CLASS)
|
||||
invoke(options, "setBasePath", baseDir.absolutePath)
|
||||
invoke(options, "setWorkingPath", workingDir.absolutePath)
|
||||
invoke(options, "setTempPath", tempDir.absolutePath)
|
||||
invoke(options, "setFixAndroidStack", true)
|
||||
invoke(options, "setCommandServerListenPort", 0)
|
||||
invoke(options, "setCommandServerSecret", "")
|
||||
invoke(options, "setLogMaxLines", 300L)
|
||||
invoke(options, "setDebug", false)
|
||||
invokeIfAvailable(options, "setCrashReportSource", "flutter_singbox_proxy")
|
||||
// sing-box renamed the OOM killer toggle between releases; only one of
|
||||
// these exists on the linked libbox AAR, so call whichever responds.
|
||||
invokeFirstAvailable(
|
||||
options,
|
||||
methodNames = listOf("setOomKillerDisabled", "setOomKillerEnabled"),
|
||||
args = arrayOf(true),
|
||||
)
|
||||
invokeIfAvailable(options, "setOomMemoryLimit", 0L)
|
||||
|
||||
val libbox = Class.forName(LIBBOX_CLASS)
|
||||
libbox.getMethod("setup", Class.forName(SETUP_OPTIONS_CLASS)).invoke(null, options)
|
||||
setupComplete = true
|
||||
}
|
||||
|
||||
private fun newCommandServer(): Any {
|
||||
val handlerInterface = Class.forName(COMMAND_SERVER_HANDLER_CLASS)
|
||||
val platformInterface = Class.forName(PLATFORM_INTERFACE_CLASS)
|
||||
val handler = Proxy.newProxyInstance(
|
||||
handlerInterface.classLoader,
|
||||
arrayOf(handlerInterface),
|
||||
commandServerHandler()
|
||||
)
|
||||
val platform = Proxy.newProxyInstance(
|
||||
platformInterface.classLoader,
|
||||
arrayOf(platformInterface),
|
||||
platformHandler()
|
||||
)
|
||||
return Class.forName(COMMAND_SERVER_CLASS)
|
||||
.getConstructor(handlerInterface, platformInterface)
|
||||
.newInstance(handler, platform)
|
||||
}
|
||||
|
||||
private fun ensureCommandServerStarted(server: Any) {
|
||||
if (commandServerStarted) return
|
||||
invoke(server, "start")
|
||||
commandServerStarted = true
|
||||
}
|
||||
|
||||
private fun commandServerHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"getSystemProxyStatus" -> newInstance(SYSTEM_PROXY_STATUS_CLASS).also { status ->
|
||||
invoke(status, "setAvailable", false)
|
||||
invoke(status, "setEnabled", false)
|
||||
}
|
||||
"serviceReload", "serviceStop", "setSystemProxyEnabled", "writeDebugMessage" -> null
|
||||
"triggerNativeCrash" -> throw UnsupportedOperationException("Native crash trigger is disabled")
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun platformHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"autoDetectInterfaceControl",
|
||||
"clearDNSCache",
|
||||
"closeDefaultInterfaceMonitor",
|
||||
"closeNeighborMonitor",
|
||||
"registerMyInterface",
|
||||
"sendNotification",
|
||||
"startDefaultInterfaceMonitor",
|
||||
"startNeighborMonitor" -> null
|
||||
"findConnectionOwner" -> newInstance(CONNECTION_OWNER_CLASS).also { owner ->
|
||||
invoke(owner, "setUserId", -1)
|
||||
invoke(owner, "setUserName", "")
|
||||
invoke(owner, "setProcessPath", "")
|
||||
invoke(owner, "setAndroidPackageNames", emptyStringIterator())
|
||||
}
|
||||
"getInterfaces" -> emptyIterator(NETWORK_INTERFACE_ITERATOR_CLASS)
|
||||
"includeAllNetworks",
|
||||
"underNetworkExtension",
|
||||
"usePlatformAutoDetectInterfaceControl",
|
||||
"useProcFS" -> false
|
||||
"localDNSTransport" -> createLocalDnsTransport()
|
||||
"openTun" -> throw UnsupportedOperationException("TUN is not supported by WebLibre proxy routing")
|
||||
"readWIFIState" -> Class.forName(WIFI_STATE_CLASS)
|
||||
.getConstructor(String::class.java, String::class.java)
|
||||
.newInstance("", "")
|
||||
"systemCertificates" -> emptyStringIterator()
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLogClientConnected() {
|
||||
if (commandClient != null) return
|
||||
val handlerInterface = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_HANDLER_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
val optionsClass = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_OPTIONS_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
val clientClass = runCatching {
|
||||
Class.forName(COMMAND_CLIENT_CLASS)
|
||||
}.getOrNull() ?: return
|
||||
|
||||
val handler = Proxy.newProxyInstance(
|
||||
handlerInterface.classLoader,
|
||||
arrayOf(handlerInterface),
|
||||
commandClientHandler()
|
||||
)
|
||||
val options = optionsClass.getConstructor().newInstance()
|
||||
// Subscribe to the log stream (CommandLog == 0 in sing-box/libbox).
|
||||
invoke(options, "addCommand", 0)
|
||||
// Subscribe to connection events (CommandConnections == 4). Some
|
||||
// transports, including WireGuard endpoint routing, don't emit useful
|
||||
// per-connection lines through the regular log stream.
|
||||
invoke(options, "addCommand", 4)
|
||||
val client = clientClass
|
||||
.getConstructor(handlerInterface, optionsClass)
|
||||
.newInstance(handler, options)
|
||||
commandClient = client
|
||||
|
||||
// Connect dials the local command socket with retries; do it off the
|
||||
// platform thread so we don't block start().
|
||||
val thread = Thread({
|
||||
runCatching { invoke(client, "connect") }
|
||||
.onFailure { error ->
|
||||
logSink?.invoke(3, "sing-box log stream connection failed: ${error.message}")
|
||||
}
|
||||
}, "singbox-log-client")
|
||||
thread.isDaemon = true
|
||||
thread.start()
|
||||
logClientThread = thread
|
||||
}
|
||||
|
||||
private fun disconnectLogClient() {
|
||||
val client = commandClient ?: return
|
||||
commandClient = null
|
||||
runCatching { invoke(client, "disconnect") }
|
||||
logClientThread = null
|
||||
}
|
||||
|
||||
private fun commandClientHandler(): InvocationHandler {
|
||||
return InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"writeLogs" -> {
|
||||
val iterator = args?.firstOrNull()
|
||||
if (iterator != null) {
|
||||
forwardLogIterator(iterator)
|
||||
}
|
||||
null
|
||||
}
|
||||
"clearLogs",
|
||||
"connected",
|
||||
"disconnected",
|
||||
"setDefaultLogLevel",
|
||||
"writeStatus",
|
||||
"writeGroups",
|
||||
"writeOutbounds",
|
||||
"initializeClashMode",
|
||||
"updateClashMode" -> null
|
||||
"writeConnectionEvents" -> {
|
||||
val events = args?.firstOrNull()
|
||||
if (events != null) {
|
||||
forwardConnectionEvents(events)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardLogIterator(iterator: Any) {
|
||||
val sink = logSink ?: return
|
||||
runCatching {
|
||||
while (invoke(iterator, "hasNext") as? Boolean == true) {
|
||||
val entry = invoke(iterator, "next") ?: continue
|
||||
val level = (runCatching { invoke(entry, "getLevel") }.getOrNull() as? Number)
|
||||
?.toInt() ?: 0
|
||||
val message = runCatching { invoke(entry, "getMessage") }
|
||||
.getOrNull() as? String ?: continue
|
||||
sink(level, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardConnectionEvents(events: Any) {
|
||||
val sink = logSink ?: return
|
||||
runCatching {
|
||||
val iterator = invokeFirstAvailableResult(events, listOf("iterator", "Iterator")) ?: return
|
||||
while (invoke(iterator, "hasNext") as? Boolean == true) {
|
||||
val event = invoke(iterator, "next") ?: continue
|
||||
val type = (invokeFirstAvailableResult(event, listOf("getType", "type")) as? Number)
|
||||
?.toInt() ?: continue
|
||||
if (type != CONNECTION_EVENT_NEW && type != CONNECTION_EVENT_CLOSED) continue
|
||||
|
||||
val connection = invokeFirstAvailableResult(
|
||||
event,
|
||||
listOf("getConnection", "connection"),
|
||||
) ?: continue
|
||||
val eventLabel = if (type == CONNECTION_EVENT_CLOSED) "closed" else "opened"
|
||||
sink(4, "connection $eventLabel ${describeConnection(connection)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeConnection(connection: Any): String {
|
||||
val network = stringValue(connection, "getNetwork", "network")
|
||||
val source = stringValue(connection, "getSource", "source")
|
||||
val destination = stringValue(
|
||||
connection,
|
||||
"displayDestination",
|
||||
"DisplayDestination",
|
||||
"getDestination",
|
||||
"destination",
|
||||
)
|
||||
val outbound = stringValue(connection, "getOutbound", "outbound")
|
||||
val inbound = stringValue(connection, "getInbound", "inbound")
|
||||
|
||||
return buildString {
|
||||
if (network.isNotBlank()) append(network).append(' ')
|
||||
if (source.isNotBlank()) append(source).append(" -> ")
|
||||
append(destination.ifBlank { "unknown destination" })
|
||||
if (outbound.isNotBlank()) append(" via ").append(outbound)
|
||||
if (inbound.isNotBlank()) append(" (").append(inbound).append(')')
|
||||
}
|
||||
}
|
||||
|
||||
private fun stringValue(target: Any, vararg methodNames: String): String {
|
||||
return invokeFirstAvailableResult(target, methodNames.toList()) as? String ?: ""
|
||||
}
|
||||
|
||||
private fun createLocalDnsTransport(): Any? {
|
||||
val iface = runCatching {
|
||||
Class.forName(LOCAL_DNS_TRANSPORT_CLASS)
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
iface.classLoader,
|
||||
arrayOf(iface),
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"raw" -> true
|
||||
"exchange" -> {
|
||||
val ctx = args?.getOrNull(0)
|
||||
val request = args?.getOrNull(1) as? ByteArray
|
||||
if (ctx != null && request != null) {
|
||||
runDohExchange(ctx, request)
|
||||
}
|
||||
null
|
||||
}
|
||||
// Lookup is only reachable when raw() returns false. We
|
||||
// always return true above, so this path is dead.
|
||||
"lookup" -> null
|
||||
else -> defaultValue(method.returnType, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runDohExchange(ctx: Any, request: ByteArray) {
|
||||
val url = bootstrapDohUrl
|
||||
if (url == null) {
|
||||
// No bootstrap URL configured — return SERVFAIL so sing-box gets a
|
||||
// clean failure instead of hanging on a half-initialized bridge.
|
||||
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val response = dohResolver.exchange(url, request)
|
||||
invokeIfAvailable(ctx, "rawSuccess", response)
|
||||
} catch (error: Throwable) {
|
||||
logSink?.invoke(3, "DoH bootstrap exchange failed: ${error.message}")
|
||||
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyStringIterator(): Any = emptyIterator(STRING_ITERATOR_CLASS)
|
||||
|
||||
private fun emptyIterator(interfaceName: String): Any {
|
||||
val iteratorInterface = Class.forName(interfaceName)
|
||||
return Proxy.newProxyInstance(
|
||||
iteratorInterface.classLoader,
|
||||
arrayOf(iteratorInterface),
|
||||
) { _, method, _ ->
|
||||
when (method.name) {
|
||||
"hasNext" -> false
|
||||
"len" -> 0
|
||||
"next" -> null
|
||||
else -> defaultValue(method.returnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun newInstance(className: String): Any {
|
||||
return Class.forName(className).getConstructor().newInstance()
|
||||
}
|
||||
|
||||
private fun invoke(target: Any, methodName: String, vararg args: Any?): Any? {
|
||||
val method = findMethod(target.javaClass, methodName, args.size)
|
||||
return method.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun invokeIfAvailable(target: Any, methodName: String, vararg args: Any?) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == methodName && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
method?.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun invokeFirstAvailable(
|
||||
target: Any,
|
||||
methodNames: List<String>,
|
||||
args: Array<Any?>,
|
||||
) {
|
||||
for (name in methodNames) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
if (method != null) {
|
||||
method.invoke(target, *args)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeFirstAvailableResult(
|
||||
target: Any,
|
||||
methodNames: List<String>,
|
||||
vararg args: Any?,
|
||||
): Any? {
|
||||
for (name in methodNames) {
|
||||
val method = target.javaClass.methods.firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterTypes.size == args.size
|
||||
}
|
||||
if (method != null) {
|
||||
return method.invoke(target, *args)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findMethod(clazz: Class<*>, methodName: String, argCount: Int): Method {
|
||||
return clazz.methods.firstOrNull { method ->
|
||||
method.name == methodName && method.parameterTypes.size == argCount
|
||||
} ?: throw NoSuchMethodError(
|
||||
"${clazz.name}.$methodName($argCount args) is missing — linked libbox AAR " +
|
||||
"may be incompatible. Available '${methodName}' overloads: " +
|
||||
clazz.methods
|
||||
.filter { it.name == methodName }
|
||||
.joinToString { "${it.name}(${it.parameterTypes.joinToString { p -> p.simpleName }})" }
|
||||
.ifEmpty { "<none>" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultValue(returnType: Class<*>, args: Array<Any?>? = null): Any? {
|
||||
return when (returnType) {
|
||||
java.lang.Boolean.TYPE -> false
|
||||
java.lang.Integer.TYPE -> 0
|
||||
java.lang.Long.TYPE -> 0L
|
||||
java.lang.Float.TYPE -> 0f
|
||||
java.lang.Double.TYPE -> 0.0
|
||||
java.lang.Void.TYPE -> null
|
||||
else -> args?.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIBBOX_CLASS = "io.nekohasekai.libbox.Libbox"
|
||||
const val SETUP_OPTIONS_CLASS = "io.nekohasekai.libbox.SetupOptions"
|
||||
const val COMMAND_SERVER_CLASS = "io.nekohasekai.libbox.CommandServer"
|
||||
const val COMMAND_SERVER_HANDLER_CLASS = "io.nekohasekai.libbox.CommandServerHandler"
|
||||
const val PLATFORM_INTERFACE_CLASS = "io.nekohasekai.libbox.PlatformInterface"
|
||||
const val LOCAL_DNS_TRANSPORT_CLASS = "io.nekohasekai.libbox.LocalDNSTransport"
|
||||
const val DNS_RCODE_SERVFAIL = 2
|
||||
const val OVERRIDE_OPTIONS_CLASS = "io.nekohasekai.libbox.OverrideOptions"
|
||||
const val STRING_ITERATOR_CLASS = "io.nekohasekai.libbox.StringIterator"
|
||||
const val NETWORK_INTERFACE_ITERATOR_CLASS = "io.nekohasekai.libbox.NetworkInterfaceIterator"
|
||||
const val CONNECTION_OWNER_CLASS = "io.nekohasekai.libbox.ConnectionOwner"
|
||||
const val SYSTEM_PROXY_STATUS_CLASS = "io.nekohasekai.libbox.SystemProxyStatus"
|
||||
const val WIFI_STATE_CLASS = "io.nekohasekai.libbox.WIFIState"
|
||||
const val COMMAND_CLIENT_CLASS = "io.nekohasekai.libbox.CommandClient"
|
||||
const val COMMAND_CLIENT_HANDLER_CLASS = "io.nekohasekai.libbox.CommandClientHandler"
|
||||
const val COMMAND_CLIENT_OPTIONS_CLASS = "io.nekohasekai.libbox.CommandClientOptions"
|
||||
const val CONNECTION_EVENT_NEW = 0
|
||||
const val CONNECTION_EVENT_CLOSED = 2
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* RFC 8484 DoH client used as the bootstrap resolver for sing-box's
|
||||
* `type: "local"` DNS transport.
|
||||
*
|
||||
* Sing-box calls the platform LocalDNSTransport whenever a DNS server's own
|
||||
* hostname (or any other hostname referenced by `domain_resolver` /
|
||||
* `default_domain_resolver`) needs resolving. We bounce the wire-format query
|
||||
* straight to a configured DoH endpoint so no query ever hits Android's
|
||||
* system resolver.
|
||||
*
|
||||
* The DoH endpoint's own hostname is resolved exactly once by the JVM HTTP
|
||||
* stack. Configure the URL with an IP literal (e.g. `https://1.1.1.1/dns-query`)
|
||||
* if even that one lookup must not leak.
|
||||
*/
|
||||
class PlatformDohResolver(
|
||||
private val connectTimeoutMillis: Int = 5_000,
|
||||
private val readTimeoutMillis: Int = 5_000,
|
||||
) {
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Send [request] as a DoH POST and return the raw DNS wire-format reply. */
|
||||
@Throws(IOException::class)
|
||||
fun exchange(url: String, request: ByteArray): ByteArray {
|
||||
val httpRequest = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", DNS_MESSAGE_MIME)
|
||||
.post(request.toRequestBody(DNS_MESSAGE_MEDIA_TYPE))
|
||||
.build()
|
||||
|
||||
client.newCall(httpRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException(
|
||||
"DoH endpoint returned HTTP ${response.code} via ${response.protocol}"
|
||||
)
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
if (!contentType.startsWith(DNS_MESSAGE_MIME, ignoreCase = true)) {
|
||||
throw IOException(
|
||||
"DoH endpoint returned unexpected Content-Type: $contentType"
|
||||
)
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IOException("DoH endpoint returned no body")
|
||||
val contentLength = body.contentLength()
|
||||
if (contentLength > MAX_DNS_RESPONSE) {
|
||||
throw IOException("DoH response exceeds maximum DNS message size")
|
||||
}
|
||||
|
||||
return body.bytes().also { bytes ->
|
||||
if (bytes.size > MAX_DNS_RESPONSE) {
|
||||
throw IOException("DoH response exceeds maximum DNS message size")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DNS_MESSAGE_MIME = "application/dns-message"
|
||||
val DNS_MESSAGE_MEDIA_TYPE = DNS_MESSAGE_MIME.toMediaType()
|
||||
// EDNS0 typically caps responses at 4 KiB; we allow a little slack.
|
||||
const val MAX_DNS_RESPONSE = 8 * 1024
|
||||
}
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import java.net.URI
|
||||
import java.security.SecureRandom
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val LOCALHOST = "127.0.0.1"
|
||||
private const val DEFAULT_BASE_PORT = 12000L
|
||||
private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
|
||||
/**
|
||||
* Tag of the always-emitted `type: "local"` DNS server. Hooked at runtime by
|
||||
* our LocalDNSTransport bridge (PlatformDohResolver), so every hostname in
|
||||
* the config — including DoH endpoint hostnames and WireGuard peer
|
||||
* hostnames — resolves through DoH instead of `/etc/resolv.conf`.
|
||||
*/
|
||||
private const val DNS_BOOTSTRAP_TAG = "local"
|
||||
|
||||
class SingboxConfigBuilder(
|
||||
private val random: SecureRandom = SecureRandom()
|
||||
) {
|
||||
fun validateProfile(profile: SingboxProxyProfile): String? {
|
||||
if (profile.id.isBlank()) return "Profile id is required."
|
||||
if (profile.name.isBlank()) return "Profile name is required."
|
||||
|
||||
val outbound = try {
|
||||
buildOutbound(profile)
|
||||
} catch (error: JSONException) {
|
||||
return "Invalid outbound JSON: ${error.message}"
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
val expectedType = expectedOutboundType(profile.type)
|
||||
val actualType = outbound.optString("type")
|
||||
if (expectedType != null && actualType != expectedType) {
|
||||
return "Profile type ${profile.type.name} requires sing-box outbound type '$expectedType'."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun build(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions
|
||||
): SingboxProxyConfigResult {
|
||||
profiles.forEach { profile ->
|
||||
validateProfile(profile)?.let { throw IllegalArgumentException(it) }
|
||||
}
|
||||
|
||||
val inbounds = JSONArray()
|
||||
val endpointsJson = JSONArray()
|
||||
val outbounds = JSONArray()
|
||||
val rules = JSONArray()
|
||||
val endpoints = mutableListOf<SingboxProxyRuntimeEndpoint>()
|
||||
val basePort = options.preferredBasePort ?: DEFAULT_BASE_PORT
|
||||
|
||||
profiles.forEachIndexed { index, profile ->
|
||||
val inboundTag = inboundTag(profile.id)
|
||||
val outboundTag = outboundTag(profile.id)
|
||||
val port = basePort + index
|
||||
val username = generateToken("u")
|
||||
val password = generateToken("p")
|
||||
|
||||
inbounds.put(JSONObject().apply {
|
||||
put("type", "socks")
|
||||
put("tag", inboundTag)
|
||||
put("listen", LOCALHOST)
|
||||
put("listen_port", port)
|
||||
put("users", JSONArray().put(JSONObject().apply {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
}))
|
||||
})
|
||||
|
||||
if (profile.type == SingboxProxyProfileType.WIREGUARD) {
|
||||
endpointsJson.put(buildWireGuardEndpoint(profile, outboundTag))
|
||||
} else {
|
||||
outbounds.put(buildOutbound(profile).apply {
|
||||
put("tag", outboundTag)
|
||||
})
|
||||
}
|
||||
|
||||
rules.put(JSONObject().apply {
|
||||
put("inbound", JSONArray().put(inboundTag))
|
||||
put("action", "route")
|
||||
put("outbound", outboundTag)
|
||||
})
|
||||
|
||||
endpoints += SingboxProxyRuntimeEndpoint(
|
||||
profileId = profile.id,
|
||||
host = LOCALHOST,
|
||||
port = port,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
|
||||
val finalOutbound = if (options.blockUnmatchedTraffic) "block" else "direct"
|
||||
outbounds.put(JSONObject().apply {
|
||||
put("type", finalOutbound)
|
||||
put("tag", finalOutbound)
|
||||
})
|
||||
// Always expose a `direct` outbound even when blockUnmatchedTraffic =
|
||||
// true so internal bootstrap/fallback paths still have a direct route.
|
||||
if (finalOutbound != "direct") {
|
||||
outbounds.put(JSONObject().apply {
|
||||
put("type", "direct")
|
||||
put("tag", "direct")
|
||||
})
|
||||
}
|
||||
|
||||
val dnsBlock = options.dnsConfig?.let(::buildDnsBlock)
|
||||
if (dnsBlock != null && options.bootstrapDohUrl.isNullOrBlank()) {
|
||||
throw IllegalArgumentException(
|
||||
"bootstrapDohUrl is required when dnsConfig is provided."
|
||||
)
|
||||
}
|
||||
|
||||
val config = JSONObject().apply {
|
||||
put("log", JSONObject().apply { put("level", "info") })
|
||||
put("inbounds", inbounds)
|
||||
if (endpointsJson.length() > 0) {
|
||||
put("endpoints", endpointsJson)
|
||||
}
|
||||
put("outbounds", outbounds)
|
||||
put("route", JSONObject().apply {
|
||||
put("rules", rules)
|
||||
put("final", finalOutbound)
|
||||
// When DNS is configured, route everything's hostname
|
||||
// resolution through the platform LocalDNSTransport bridge so
|
||||
// WireGuard peer hostnames and any other outbound-dialer
|
||||
// hostname go through DoH, not /etc/resolv.conf.
|
||||
if (dnsBlock != null) {
|
||||
put("default_domain_resolver", DNS_BOOTSTRAP_TAG)
|
||||
}
|
||||
})
|
||||
dnsBlock?.let { put("dns", it) }
|
||||
}
|
||||
|
||||
return SingboxProxyConfigResult(
|
||||
configJson = config.toString(2),
|
||||
endpoints = endpoints
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDnsBlock(dns: SingboxProxyDnsConfig): JSONObject? {
|
||||
if (dns.servers.isEmpty()) return null
|
||||
|
||||
val serversJson = JSONArray()
|
||||
val rulesJson = JSONArray()
|
||||
|
||||
// Always emit the platform `local` server first. It is the foundation
|
||||
// every other server's `domain_resolver` (and the route's
|
||||
// default_domain_resolver) points at, and the LocalDNSTransport
|
||||
// bridge backs it with DoH at runtime.
|
||||
serversJson.put(JSONObject().apply {
|
||||
put("type", "local")
|
||||
put("tag", DNS_BOOTSTRAP_TAG)
|
||||
})
|
||||
|
||||
for (server in dns.servers) {
|
||||
serversJson.put(buildDnsServer(server))
|
||||
|
||||
if (server.matchDomainSuffixes.isNotEmpty() ||
|
||||
server.matchInbounds.isNotEmpty()
|
||||
) {
|
||||
rulesJson.put(JSONObject().apply {
|
||||
if (server.matchDomainSuffixes.isNotEmpty()) {
|
||||
put(
|
||||
"domain_suffix",
|
||||
JSONArray(server.matchDomainSuffixes)
|
||||
)
|
||||
}
|
||||
if (server.matchInbounds.isNotEmpty()) {
|
||||
put("inbound", JSONArray(server.matchInbounds))
|
||||
}
|
||||
put("action", "route")
|
||||
put("server", server.tag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return JSONObject().apply {
|
||||
put("servers", serversJson)
|
||||
if (rulesJson.length() > 0) {
|
||||
put("rules", rulesJson)
|
||||
}
|
||||
if (dns.domainStrategy.isNotBlank()) {
|
||||
put("strategy", dns.domainStrategy)
|
||||
}
|
||||
val finalTag = dns.finalServerTag ?: DNS_BOOTSTRAP_TAG
|
||||
put("final", finalTag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDnsServer(server: SingboxProxyDnsServerConfig): JSONObject {
|
||||
val parsed = parseDnsAddress(server.address)
|
||||
return JSONObject().apply {
|
||||
put("type", parsed.type)
|
||||
put("tag", server.tag)
|
||||
parsed.server?.let { put("server", it) }
|
||||
parsed.serverPort?.let { put("server_port", it) }
|
||||
parsed.path?.let { put("path", it) }
|
||||
server.detourTag?.takeUnless { it == "direct" }?.let { put("detour", it) }
|
||||
// Hostname targets always bootstrap through `local`; sing-box
|
||||
// ignores `domain_resolver` for IP-literal servers, so emitting
|
||||
// it unconditionally is fine and keeps the JSON uniform.
|
||||
if (parsed.server != null && !parsed.serverIsIpLiteral) {
|
||||
put("domain_resolver", DNS_BOOTSTRAP_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDnsAddress(address: String): ParsedDnsAddress {
|
||||
val trimmed = address.trim()
|
||||
if (trimmed == "local") {
|
||||
return ParsedDnsAddress(type = "local")
|
||||
}
|
||||
|
||||
val uri = if (trimmed.contains("://")) URI(trimmed) else null
|
||||
val scheme = uri?.scheme?.lowercase()
|
||||
return when (scheme) {
|
||||
null -> parseHostPort(trimmed, "udp", 53)
|
||||
"udp" -> parseUriHostPort(uri!!, "udp", 53)
|
||||
"tcp" -> parseUriHostPort(uri!!, "tcp", 53)
|
||||
"tls" -> parseUriHostPort(uri!!, "tls", 853)
|
||||
"quic" -> parseUriHostPort(uri!!, "quic", 853)
|
||||
"https", "h3" -> parseUriHostPort(uri, scheme, 443).copy(
|
||||
path = uri.path.takeUnless { it.isNullOrBlank() || it == "/dns-query" }
|
||||
)
|
||||
else -> throw IllegalArgumentException("Unsupported DNS server scheme: $scheme")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUriHostPort(uri: URI, type: String, defaultPort: Int): ParsedDnsAddress {
|
||||
val host = uri.host ?: throw IllegalArgumentException("Invalid DNS server address")
|
||||
val bare = host.removePrefix("[").removeSuffix("]")
|
||||
return ParsedDnsAddress(
|
||||
type = type,
|
||||
server = bare,
|
||||
serverPort = uri.port.takeIf { it >= 0 && it != defaultPort },
|
||||
serverIsIpLiteral = isIpLiteral(bare),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseHostPort(value: String, type: String, defaultPort: Int): ParsedDnsAddress {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) throw IllegalArgumentException("DNS server address is required")
|
||||
|
||||
val splitPort = trimmed.lastIndexOf(':')
|
||||
val hasSingleColon = splitPort > 0 && trimmed.indexOf(':') == splitPort
|
||||
val host = if (hasSingleColon) trimmed.substring(0, splitPort) else trimmed
|
||||
val port = if (hasSingleColon) trimmed.substring(splitPort + 1).toIntOrNull() else null
|
||||
val bare = host.removePrefix("[").removeSuffix("]")
|
||||
|
||||
return ParsedDnsAddress(
|
||||
type = type,
|
||||
server = bare,
|
||||
serverPort = port?.takeUnless { it == defaultPort },
|
||||
serverIsIpLiteral = isIpLiteral(bare),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isIpLiteral(host: String): Boolean {
|
||||
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) return true
|
||||
if (host.contains(":") && host.matches(Regex("^[0-9A-Fa-f:.]+$"))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private data class ParsedDnsAddress(
|
||||
val type: String,
|
||||
val server: String? = null,
|
||||
val serverPort: Int? = null,
|
||||
val path: String? = null,
|
||||
val serverIsIpLiteral: Boolean = false,
|
||||
)
|
||||
|
||||
private fun buildOutbound(profile: SingboxProxyProfile): JSONObject {
|
||||
val outbound = JSONObject(profile.configJson)
|
||||
profile.secretJson?.takeIf { it.isNotBlank() }?.let { secretJson ->
|
||||
deepMerge(outbound, JSONObject(secretJson))
|
||||
}
|
||||
|
||||
expectedOutboundType(profile.type)?.let { expectedType ->
|
||||
val actualType = outbound.optString("type")
|
||||
if (actualType.isBlank()) {
|
||||
outbound.put("type", expectedType)
|
||||
}
|
||||
}
|
||||
|
||||
return outbound
|
||||
}
|
||||
|
||||
private fun buildWireGuardEndpoint(profile: SingboxProxyProfile, tag: String): JSONObject {
|
||||
val endpoint = buildOutbound(profile)
|
||||
endpoint.put("tag", tag)
|
||||
|
||||
endpoint.remove("server")?.let { server ->
|
||||
val peer = JSONObject().apply {
|
||||
put("address", server)
|
||||
endpoint.remove("server_port")?.let { put("port", it) }
|
||||
endpoint.remove("peer_public_key")?.let { put("public_key", it) }
|
||||
endpoint.remove("pre_shared_key")?.let { put("pre_shared_key", it) }
|
||||
endpoint.remove("reserved")?.let { put("reserved", it) }
|
||||
endpoint.remove("persistent_keepalive_interval")?.let {
|
||||
put("persistent_keepalive_interval", it)
|
||||
}
|
||||
put("allowed_ips", endpoint.remove("allowed_ips") ?: JSONArray().apply {
|
||||
put("0.0.0.0/0")
|
||||
put("::/0")
|
||||
})
|
||||
}
|
||||
endpoint.put("peers", JSONArray().put(peer))
|
||||
}
|
||||
|
||||
endpoint.remove("local_address")?.let { endpoint.put("address", it) }
|
||||
endpoint.remove("system_interface")?.let { endpoint.put("system", it) }
|
||||
endpoint.remove("interface_name")?.let { endpoint.put("name", it) }
|
||||
endpoint.remove("gso")
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun deepMerge(target: JSONObject, source: JSONObject) {
|
||||
val keys = source.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val value = source.get(key)
|
||||
if (value is JSONObject && target.opt(key) is JSONObject) {
|
||||
deepMerge(target.getJSONObject(key), value)
|
||||
} else {
|
||||
target.put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateToken(prefix: String): String {
|
||||
val bytes = ByteArray(18)
|
||||
random.nextBytes(bytes)
|
||||
return prefix + base64UrlNoPadding(bytes)
|
||||
}
|
||||
|
||||
private fun base64UrlNoPadding(bytes: ByteArray): String {
|
||||
val output = StringBuilder((bytes.size * 4 + 2) / 3)
|
||||
var index = 0
|
||||
while (index < bytes.size) {
|
||||
val b0 = bytes[index++].toInt() and 0xff
|
||||
val b1 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
|
||||
val b2 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
|
||||
|
||||
output.append(BASE64_URL_ALPHABET[b0 ushr 2])
|
||||
if (b1 < 0) {
|
||||
output.append(BASE64_URL_ALPHABET[(b0 and 0x03) shl 4])
|
||||
} else {
|
||||
output.append(BASE64_URL_ALPHABET[((b0 and 0x03) shl 4) or (b1 ushr 4)])
|
||||
if (b2 < 0) {
|
||||
output.append(BASE64_URL_ALPHABET[(b1 and 0x0f) shl 2])
|
||||
} else {
|
||||
output.append(BASE64_URL_ALPHABET[((b1 and 0x0f) shl 2) or (b2 ushr 6)])
|
||||
output.append(BASE64_URL_ALPHABET[b2 and 0x3f])
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun inboundTag(profileId: String) = SingboxTagFormat.inboundTag(profileId)
|
||||
|
||||
private fun outboundTag(profileId: String) = SingboxTagFormat.outboundTag(profileId)
|
||||
|
||||
private fun expectedOutboundType(type: SingboxProxyProfileType): String? = when (type) {
|
||||
SingboxProxyProfileType.SOCKS -> "socks"
|
||||
SingboxProxyProfileType.HTTP -> "http"
|
||||
SingboxProxyProfileType.SHADOWSOCKS -> "shadowsocks"
|
||||
SingboxProxyProfileType.VMESS -> "vmess"
|
||||
SingboxProxyProfileType.VLESS -> "vless"
|
||||
SingboxProxyProfileType.TROJAN -> "trojan"
|
||||
SingboxProxyProfileType.NAIVE -> "naive"
|
||||
SingboxProxyProfileType.HYSTERIA -> "hysteria"
|
||||
SingboxProxyProfileType.HYSTERIA2 -> "hysteria2"
|
||||
SingboxProxyProfileType.TUIC -> "tuic"
|
||||
SingboxProxyProfileType.SSH -> "ssh"
|
||||
SingboxProxyProfileType.WIREGUARD -> "wireguard"
|
||||
SingboxProxyProfileType.SHADOW_TLS -> "shadowtls"
|
||||
SingboxProxyProfileType.ANY_TLS -> "anytls"
|
||||
SingboxProxyProfileType.CUSTOM_OUTBOUND -> null
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class SingboxRuntimeManager(
|
||||
context: Context,
|
||||
private val configBuilder: SingboxConfigBuilder = SingboxConfigBuilder(),
|
||||
private val libboxRuntime: LibboxRuntime = LibboxRuntime(context),
|
||||
private val onStateChanged: (SingboxProxyRuntimeState) -> Unit = {},
|
||||
private val onLogMessage: (SingboxProxyLogMessage) -> Unit = {},
|
||||
private val dispatchToMain: ((() -> Unit) -> Unit) = { action ->
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
action()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post(action)
|
||||
}
|
||||
}
|
||||
) : SingboxProxyApi {
|
||||
// Pigeon dispatches Dart-side calls on the platform thread, but libbox
|
||||
// callbacks fire from native threads. Guard all state transitions with
|
||||
// a single lock so concurrent stop / start / event paths cannot tear
|
||||
// activeProfiles, activeOptions, or `state` against each other.
|
||||
private val stateLock = Any()
|
||||
private val runtimeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "singbox-runtime").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
private var state = SingboxProxyRuntimeState(
|
||||
status = SingboxProxyRuntimeStatus.STOPPED,
|
||||
endpoints = emptyList(),
|
||||
message = null
|
||||
)
|
||||
private var activeProfiles = emptyList<SingboxProxyProfile>()
|
||||
private var activeOptions = SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = null,
|
||||
blockUnmatchedTraffic = true
|
||||
)
|
||||
|
||||
init {
|
||||
// Forward every sing-box log entry to Dart. libbox levels follow
|
||||
// sing/common/logger: 0 = panic, 1 = fatal, 2 = error, 3 = warn,
|
||||
// 4 = info, 5 = debug, 6 = trace.
|
||||
libboxRuntime.setLogSink { level, message ->
|
||||
emitLogMessage(level, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun libboxLogLevelName(level: Int): String = when (level) {
|
||||
0 -> "panic"
|
||||
1 -> "fatal"
|
||||
2 -> "error"
|
||||
3 -> "warn"
|
||||
4 -> "info"
|
||||
5 -> "debug"
|
||||
6 -> "trace"
|
||||
else -> "info"
|
||||
}
|
||||
|
||||
private fun emitLogMessage(level: Int, message: String) {
|
||||
emitLogMessage(
|
||||
SingboxProxyLogMessage(
|
||||
level = libboxLogLevelName(level),
|
||||
message = message,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
profileId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitLogMessage(logMessage: SingboxProxyLogMessage) {
|
||||
dispatchToMain {
|
||||
onLogMessage(logMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitStateChanged(nextState: SingboxProxyRuntimeState) {
|
||||
dispatchToMain {
|
||||
onStateChanged(nextState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLogMessage(
|
||||
status: SingboxProxyRuntimeStatus,
|
||||
message: String
|
||||
) = SingboxProxyLogMessage(
|
||||
level = if (status == SingboxProxyRuntimeStatus.ERROR) "warn" else "info",
|
||||
message = message,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
profileId = null
|
||||
)
|
||||
|
||||
override fun validateProfile(
|
||||
profile: SingboxProxyProfile,
|
||||
callback: (Result<String?>) -> Unit
|
||||
) {
|
||||
callback(Result.success(configBuilder.validateProfile(profile)))
|
||||
}
|
||||
|
||||
override fun buildConfig(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions,
|
||||
callback: (Result<SingboxProxyConfigResult>) -> Unit
|
||||
) {
|
||||
runCatching { configBuilder.build(profiles, options) }
|
||||
.onSuccess { callback(Result.success(it)) }
|
||||
.onFailure { callback(Result.failure(it)) }
|
||||
}
|
||||
|
||||
override fun start(
|
||||
profiles: List<SingboxProxyProfile>,
|
||||
options: SingboxProxyRuntimeOptions,
|
||||
callback: (Result<SingboxProxyRuntimeState>) -> Unit
|
||||
) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
val previousState = state
|
||||
runCatching {
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STARTING,
|
||||
emptyList(),
|
||||
"Building sing-box config"
|
||||
)
|
||||
val config = configBuilder.build(profiles, options)
|
||||
|
||||
if (!libboxRuntime.isAvailable()) {
|
||||
val message = "sing-box libbox runtime is not linked"
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
emptyList(),
|
||||
message
|
||||
)
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
val previousBootstrapDohUrl = activeOptions.bootstrapDohUrl
|
||||
libboxRuntime.setBootstrapDohUrl(options.bootstrapDohUrl)
|
||||
try {
|
||||
libboxRuntime.start(config.configJson)
|
||||
} catch (error: Throwable) {
|
||||
libboxRuntime.setBootstrapDohUrl(previousBootstrapDohUrl)
|
||||
throw error
|
||||
}
|
||||
// Only commit profiles/options after start() returns without
|
||||
// throwing, so a failed start leaves the previous active set
|
||||
// intact rather than half-replaced.
|
||||
activeProfiles = profiles
|
||||
activeOptions = options
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.RUNNING,
|
||||
config.endpoints,
|
||||
null
|
||||
)
|
||||
state
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
previousState.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
runCatching {
|
||||
val remaining = activeProfiles.filterNot { profile ->
|
||||
profile.id in profileIds
|
||||
}
|
||||
if (remaining.isEmpty()) {
|
||||
libboxRuntime.stopService()
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STOPPED,
|
||||
emptyList(),
|
||||
null
|
||||
)
|
||||
} else {
|
||||
val config = configBuilder.build(remaining, activeOptions)
|
||||
// Partial stop keeps activeOptions.bootstrapDohUrl, so
|
||||
// no setBootstrapDohUrl call is needed here — the
|
||||
// libbox bridge already holds the right URL from the
|
||||
// most recent start().
|
||||
libboxRuntime.start(config.configJson)
|
||||
activeProfiles = remaining
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.RUNNING,
|
||||
config.endpoints,
|
||||
null
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
state.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopAll(callback: (Result<Unit>) -> Unit) {
|
||||
runtimeExecutor.execute {
|
||||
val result = synchronized(stateLock) {
|
||||
runCatching {
|
||||
libboxRuntime.stopService()
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.STOPPED,
|
||||
emptyList(),
|
||||
null
|
||||
)
|
||||
}.onFailure { error ->
|
||||
updateStateLocked(
|
||||
SingboxProxyRuntimeStatus.ERROR,
|
||||
state.endpoints,
|
||||
error.message ?: error::class.java.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchToMain { callback(result) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(): SingboxProxyRuntimeState = synchronized(stateLock) { state }
|
||||
|
||||
fun close() {
|
||||
runtimeExecutor.execute {
|
||||
synchronized(stateLock) {
|
||||
runCatching { libboxRuntime.stopService() }
|
||||
activeProfiles = emptyList()
|
||||
activeOptions = defaultRuntimeOptions()
|
||||
libboxRuntime.setBootstrapDohUrl(null)
|
||||
libboxRuntime.close()
|
||||
state = SingboxProxyRuntimeState(
|
||||
status = SingboxProxyRuntimeStatus.STOPPED,
|
||||
endpoints = emptyList(),
|
||||
message = null
|
||||
)
|
||||
}
|
||||
}
|
||||
runtimeExecutor.shutdown()
|
||||
}
|
||||
|
||||
private fun updateStateLocked(
|
||||
status: SingboxProxyRuntimeStatus,
|
||||
endpoints: List<SingboxProxyRuntimeEndpoint>,
|
||||
message: String?
|
||||
) {
|
||||
state = SingboxProxyRuntimeState(
|
||||
status = status,
|
||||
endpoints = endpoints,
|
||||
message = message
|
||||
)
|
||||
val snapshot = state
|
||||
emitStateChanged(snapshot)
|
||||
message?.let { emitLogMessage(statusLogMessage(status, it)) }
|
||||
}
|
||||
|
||||
private fun defaultRuntimeOptions() = SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = null,
|
||||
blockUnmatchedTraffic = true,
|
||||
dnsConfig = null,
|
||||
bootstrapDohUrl = null
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
/**
|
||||
* Inbound/outbound tag format shared between the Kotlin config builder and
|
||||
* the Dart DNS resolver. Changing this contract requires updating
|
||||
* `dns_config_resolver.dart` in lockstep — the Dart side emits matching tags
|
||||
* so DNS detours bind to the outbound this builder actually creates.
|
||||
*/
|
||||
internal object SingboxTagFormat {
|
||||
fun inboundTag(profileId: String): String = "in-${sanitizeTag(profileId)}"
|
||||
|
||||
fun outboundTag(profileId: String): String = "out-${sanitizeTag(profileId)}"
|
||||
|
||||
fun sanitizeTag(value: String): String {
|
||||
return value.replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||
}
|
||||
}
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
// Autogenerated from Pigeon (v26.3.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package eu.weblibre.flutter_singbox_proxy.generated
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object SingboxProxyApiPigeonUtils {
|
||||
|
||||
fun createConnectionError(channelName: String): FlutterError {
|
||||
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun floatEquals(a: Float, b: Float): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun doubleHash(d: Double): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (d == 0.0) 0.0 else d
|
||||
val bits = java.lang.Double.doubleToLongBits(normalized)
|
||||
return (bits xor (bits ushr 32)).toInt()
|
||||
}
|
||||
|
||||
fun floatHash(f: Float): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (f == 0.0f) 0.0f else f
|
||||
return java.lang.Float.floatToIntBits(normalized)
|
||||
}
|
||||
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false
|
||||
}
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!doubleEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is FloatArray && b is FloatArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!floatEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!deepEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
if (a.size != b.size) return false
|
||||
val iterA = a.iterator()
|
||||
val iterB = b.iterator()
|
||||
while (iterA.hasNext() && iterB.hasNext()) {
|
||||
if (!deepEquals(iterA.next(), iterB.next())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
if (a.size != b.size) return false
|
||||
for (entry in a) {
|
||||
val key = entry.key
|
||||
var found = false
|
||||
for (bEntry in b) {
|
||||
if (deepEquals(key, bEntry.key)) {
|
||||
if (deepEquals(entry.value, bEntry.value)) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Double && b is Double) {
|
||||
return doubleEquals(a, b)
|
||||
}
|
||||
if (a is Float && b is Float) {
|
||||
return floatEquals(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
fun deepHash(value: Any?): Int {
|
||||
return when (value) {
|
||||
null -> 0
|
||||
is ByteArray -> value.contentHashCode()
|
||||
is IntArray -> value.contentHashCode()
|
||||
is LongArray -> value.contentHashCode()
|
||||
is DoubleArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + doubleHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is FloatArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + floatHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Array<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is List<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
var result = 0
|
||||
for (entry in value) {
|
||||
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
|
||||
}
|
||||
result
|
||||
}
|
||||
is Double -> doubleHash(value)
|
||||
is Float -> floatHash(value)
|
||||
else -> value.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class SingboxProxyProfileType(val raw: Int) {
|
||||
SOCKS(0),
|
||||
HTTP(1),
|
||||
SHADOWSOCKS(2),
|
||||
VMESS(3),
|
||||
VLESS(4),
|
||||
TROJAN(5),
|
||||
NAIVE(6),
|
||||
HYSTERIA(7),
|
||||
HYSTERIA2(8),
|
||||
TUIC(9),
|
||||
SSH(10),
|
||||
WIREGUARD(11),
|
||||
SHADOW_TLS(12),
|
||||
ANY_TLS(13),
|
||||
CUSTOM_OUTBOUND(14);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): SingboxProxyProfileType? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SingboxProxyRuntimeStatus(val raw: Int) {
|
||||
STOPPED(0),
|
||||
STARTING(1),
|
||||
RUNNING(2),
|
||||
STOPPING(3),
|
||||
ERROR(4);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): SingboxProxyRuntimeStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyProfile (
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: SingboxProxyProfileType,
|
||||
/**
|
||||
* Public profile configuration as JSON. The schema is intentionally owned by
|
||||
* the profile type so the Pigeon API stays stable while sing-box evolves.
|
||||
*/
|
||||
val configJson: String,
|
||||
/**
|
||||
* Resolved secret values as JSON. Flutter stores secrets independently and
|
||||
* only passes them to native code when building or starting a runtime config.
|
||||
*/
|
||||
val secretJson: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyProfile {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val name = pigeonVar_list[1] as String
|
||||
val type = pigeonVar_list[2] as SingboxProxyProfileType
|
||||
val configJson = pigeonVar_list[3] as String
|
||||
val secretJson = pigeonVar_list[4] as String?
|
||||
return SingboxProxyProfile(id, name, type, configJson, secretJson)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
configJson,
|
||||
secretJson,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyProfile
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.id, other.id) && SingboxProxyApiPigeonUtils.deepEquals(this.name, other.name) && SingboxProxyApiPigeonUtils.deepEquals(this.type, other.type) && SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.secretJson, other.secretJson)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.id)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.name)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.type)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeOptions (
|
||||
/** Optional preferred start port for generated local SOCKS inbounds. */
|
||||
val preferredBasePort: Long? = null,
|
||||
/**
|
||||
* If true, traffic entering sing-box without a matching inbound rule is
|
||||
* rejected instead of falling through to direct.
|
||||
*/
|
||||
val blockUnmatchedTraffic: Boolean,
|
||||
/**
|
||||
* Optional DNS block emitted into sing-box config. When null, sing-box
|
||||
* uses its built-in default (system resolver), which can leak DNS outside
|
||||
* the proxy — callers should always provide an explicit configuration.
|
||||
*/
|
||||
val dnsConfig: SingboxProxyDnsConfig? = null,
|
||||
/**
|
||||
* DoH endpoint used by the native LocalDNSTransport bridge to bootstrap
|
||||
* hostname-only DNS server addresses (and any other hostname appearing in
|
||||
* the sing-box config). When null, the bridge refuses to resolve and
|
||||
* sing-box's stock `/etc/resolv.conf`/127.0.0.1:53 path runs — which is
|
||||
* broken on Android. Callers should always pass the browser DoH URL.
|
||||
*/
|
||||
val bootstrapDohUrl: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeOptions {
|
||||
val preferredBasePort = pigeonVar_list[0] as Long?
|
||||
val blockUnmatchedTraffic = pigeonVar_list[1] as Boolean
|
||||
val dnsConfig = pigeonVar_list[2] as SingboxProxyDnsConfig?
|
||||
val bootstrapDohUrl = pigeonVar_list[3] as String?
|
||||
return SingboxProxyRuntimeOptions(preferredBasePort, blockUnmatchedTraffic, dnsConfig, bootstrapDohUrl)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
preferredBasePort,
|
||||
blockUnmatchedTraffic,
|
||||
dnsConfig,
|
||||
bootstrapDohUrl,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeOptions
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.preferredBasePort, other.preferredBasePort) && SingboxProxyApiPigeonUtils.deepEquals(this.blockUnmatchedTraffic, other.blockUnmatchedTraffic) && SingboxProxyApiPigeonUtils.deepEquals(this.dnsConfig, other.dnsConfig) && SingboxProxyApiPigeonUtils.deepEquals(this.bootstrapDohUrl, other.bootstrapDohUrl)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.preferredBasePort)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.blockUnmatchedTraffic)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.dnsConfig)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyDnsServerConfig (
|
||||
/** sing-box server tag, used to reference the server from `dns.rules`. */
|
||||
val tag: String,
|
||||
/**
|
||||
* Server address. `https://...`, `tls://...`, `quic://...`, or plain IP.
|
||||
* Hostnames are resolved on demand via the platform LocalDNSTransport
|
||||
* bridge (sing-box `type: "local"` with our DoH-backed implementation).
|
||||
*/
|
||||
val address: String,
|
||||
/**
|
||||
* Outbound tag to dial the resolver through, or `direct` for direct, or
|
||||
* null when sing-box should pick automatically.
|
||||
*/
|
||||
val detourTag: String? = null,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry routing matching domains to
|
||||
* this server.
|
||||
*/
|
||||
val matchDomainSuffixes: List<String>,
|
||||
/** Advanced sing-box geosite selectors (e.g. `geosite:cn`). */
|
||||
val matchGeosites: List<String>,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry routing queries that *the
|
||||
* listed outbounds* originate to this server. Note: sing-box treats an
|
||||
* outbound's own bootstrap lookups (e.g. WireGuard peer hostname
|
||||
* resolution) as queries from that outbound, so using this for per-profile
|
||||
* scoping creates a chicken-and-egg loop at startup. Prefer
|
||||
* [matchInbounds] for "queries from tabs routed through this profile".
|
||||
*/
|
||||
val matchOutbounds: List<String>,
|
||||
/**
|
||||
* If non-empty, attaches a `dns.rules` entry matching the listed inbound
|
||||
* tags. Queries entering via that inbound (e.g. a tab whose container is
|
||||
* bound to this profile's local SOCKS inbound) resolve through this
|
||||
* server. Endpoint-bootstrap lookups don't come from any inbound, so this
|
||||
* scope safely excludes them.
|
||||
*/
|
||||
val matchInbounds: List<String>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsServerConfig {
|
||||
val tag = pigeonVar_list[0] as String
|
||||
val address = pigeonVar_list[1] as String
|
||||
val detourTag = pigeonVar_list[2] as String?
|
||||
val matchDomainSuffixes = pigeonVar_list[3] as List<String>
|
||||
val matchGeosites = pigeonVar_list[4] as List<String>
|
||||
val matchOutbounds = pigeonVar_list[5] as List<String>
|
||||
val matchInbounds = pigeonVar_list[6] as List<String>
|
||||
return SingboxProxyDnsServerConfig(tag, address, detourTag, matchDomainSuffixes, matchGeosites, matchOutbounds, matchInbounds)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
tag,
|
||||
address,
|
||||
detourTag,
|
||||
matchDomainSuffixes,
|
||||
matchGeosites,
|
||||
matchOutbounds,
|
||||
matchInbounds,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyDnsServerConfig
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.tag, other.tag) && SingboxProxyApiPigeonUtils.deepEquals(this.address, other.address) && SingboxProxyApiPigeonUtils.deepEquals(this.detourTag, other.detourTag) && SingboxProxyApiPigeonUtils.deepEquals(this.matchDomainSuffixes, other.matchDomainSuffixes) && SingboxProxyApiPigeonUtils.deepEquals(this.matchGeosites, other.matchGeosites) && SingboxProxyApiPigeonUtils.deepEquals(this.matchOutbounds, other.matchOutbounds) && SingboxProxyApiPigeonUtils.deepEquals(this.matchInbounds, other.matchInbounds)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.tag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.address)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.detourTag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchDomainSuffixes)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchGeosites)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchOutbounds)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyDnsConfig (
|
||||
val servers: List<SingboxProxyDnsServerConfig>,
|
||||
/**
|
||||
* Server tag used as `dns.final`. When null, sing-box uses the first
|
||||
* server in the list as the fallback.
|
||||
*/
|
||||
val finalServerTag: String? = null,
|
||||
/** sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`. */
|
||||
val domainStrategy: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsConfig {
|
||||
val servers = pigeonVar_list[0] as List<SingboxProxyDnsServerConfig>
|
||||
val finalServerTag = pigeonVar_list[1] as String?
|
||||
val domainStrategy = pigeonVar_list[2] as String
|
||||
return SingboxProxyDnsConfig(servers, finalServerTag, domainStrategy)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
servers,
|
||||
finalServerTag,
|
||||
domainStrategy,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyDnsConfig
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.servers, other.servers) && SingboxProxyApiPigeonUtils.deepEquals(this.finalServerTag, other.finalServerTag) && SingboxProxyApiPigeonUtils.deepEquals(this.domainStrategy, other.domainStrategy)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.servers)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.finalServerTag)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeEndpoint (
|
||||
val profileId: String,
|
||||
val host: String,
|
||||
val port: Long,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeEndpoint {
|
||||
val profileId = pigeonVar_list[0] as String
|
||||
val host = pigeonVar_list[1] as String
|
||||
val port = pigeonVar_list[2] as Long
|
||||
val username = pigeonVar_list[3] as String
|
||||
val password = pigeonVar_list[4] as String
|
||||
return SingboxProxyRuntimeEndpoint(profileId, host, port, username, password)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
profileId,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeEndpoint
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId) && SingboxProxyApiPigeonUtils.deepEquals(this.host, other.host) && SingboxProxyApiPigeonUtils.deepEquals(this.port, other.port) && SingboxProxyApiPigeonUtils.deepEquals(this.username, other.username) && SingboxProxyApiPigeonUtils.deepEquals(this.password, other.password)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.host)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.port)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.username)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyRuntimeState (
|
||||
val status: SingboxProxyRuntimeStatus,
|
||||
val endpoints: List<SingboxProxyRuntimeEndpoint>,
|
||||
val message: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeState {
|
||||
val status = pigeonVar_list[0] as SingboxProxyRuntimeStatus
|
||||
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
|
||||
val message = pigeonVar_list[2] as String?
|
||||
return SingboxProxyRuntimeState(status, endpoints, message)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
status,
|
||||
endpoints,
|
||||
message,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyRuntimeState
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.status, other.status) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.status)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyConfigResult (
|
||||
val configJson: String,
|
||||
val endpoints: List<SingboxProxyRuntimeEndpoint>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyConfigResult {
|
||||
val configJson = pigeonVar_list[0] as String
|
||||
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
|
||||
return SingboxProxyConfigResult(configJson, endpoints)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
configJson,
|
||||
endpoints,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyConfigResult
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class SingboxProxyLogMessage (
|
||||
val level: String,
|
||||
val message: String,
|
||||
val timestamp: Long,
|
||||
val profileId: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyLogMessage {
|
||||
val level = pigeonVar_list[0] as String
|
||||
val message = pigeonVar_list[1] as String
|
||||
val timestamp = pigeonVar_list[2] as Long
|
||||
val profileId = pigeonVar_list[3] as String?
|
||||
return SingboxProxyLogMessage(level, message, timestamp, profileId)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
level,
|
||||
message,
|
||||
timestamp,
|
||||
profileId,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as SingboxProxyLogMessage
|
||||
return SingboxProxyApiPigeonUtils.deepEquals(this.level, other.level) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message) && SingboxProxyApiPigeonUtils.deepEquals(this.timestamp, other.timestamp) && SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.level)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.timestamp)
|
||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
SingboxProxyProfileType.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
SingboxProxyRuntimeStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyProfile.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeOptions.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyDnsServerConfig.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyDnsConfig.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeEndpoint.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyRuntimeState.fromList(it)
|
||||
}
|
||||
}
|
||||
137.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyConfigResult.fromList(it)
|
||||
}
|
||||
}
|
||||
138.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SingboxProxyLogMessage.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is SingboxProxyProfileType -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is SingboxProxyRuntimeStatus -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is SingboxProxyProfile -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeOptions -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyDnsServerConfig -> {
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyDnsConfig -> {
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeEndpoint -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyRuntimeState -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyConfigResult -> {
|
||||
stream.write(137)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SingboxProxyLogMessage -> {
|
||||
stream.write(138)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface SingboxProxyApi {
|
||||
fun validateProfile(profile: SingboxProxyProfile, callback: (Result<String?>) -> Unit)
|
||||
fun buildConfig(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyConfigResult>) -> Unit)
|
||||
fun start(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyRuntimeState>) -> Unit)
|
||||
fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun stopAll(callback: (Result<Unit>) -> Unit)
|
||||
fun getState(): SingboxProxyRuntimeState
|
||||
|
||||
companion object {
|
||||
/** The codec used by SingboxProxyApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
SingboxProxyApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `SingboxProxyApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: SingboxProxyApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profileArg = args[0] as SingboxProxyProfile
|
||||
api.validateProfile(profileArg) { result: Result<String?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profilesArg = args[0] as List<SingboxProxyProfile>
|
||||
val optionsArg = args[1] as SingboxProxyRuntimeOptions
|
||||
api.buildConfig(profilesArg, optionsArg) { result: Result<SingboxProxyConfigResult> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profilesArg = args[0] as List<SingboxProxyProfile>
|
||||
val optionsArg = args[1] as SingboxProxyRuntimeOptions
|
||||
api.start(profilesArg, optionsArg) { result: Result<SingboxProxyRuntimeState> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val profileIdsArg = args[0] as List<String>
|
||||
api.stop(profileIdsArg) { result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.stopAll{ result: Result<Unit> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getState())
|
||||
} catch (exception: Throwable) {
|
||||
SingboxProxyApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
|
||||
class SingboxProxyEventsApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||
companion object {
|
||||
/** The codec used by SingboxProxyEventsApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
SingboxProxyApiPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onStateChanged(stateArg: SingboxProxyRuntimeState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onLogMessage(messageArg: SingboxProxyLogMessage, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(messageArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class FlutterSingboxProxyPluginTest {
|
||||
@Test
|
||||
fun buildConfig_wrapsProfileOutboundWithAuthenticatedSocksInbound() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "wg-home",
|
||||
name = "WireGuard Home",
|
||||
type = SingboxProxyProfileType.WIREGUARD,
|
||||
configJson = "{\"server\":\"example.test\"}",
|
||||
secretJson = "{\"private_key\":\"secret\"}"
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
|
||||
)
|
||||
|
||||
assertEquals(1, result.endpoints.size)
|
||||
assertEquals(12500, result.endpoints.single().port)
|
||||
assertContains(result.configJson, "\"type\": \"socks\"")
|
||||
assertContains(result.configJson, "\"type\": \"wireguard\"")
|
||||
assertContains(result.configJson, "\"endpoints\"")
|
||||
assertContains(result.configJson, "\"private_key\": \"secret\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_migratesWireGuardOutboundProfileToEndpoint() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "wg-home",
|
||||
name = "WireGuard Home",
|
||||
type = SingboxProxyProfileType.WIREGUARD,
|
||||
configJson = """
|
||||
{
|
||||
"type": "wireguard",
|
||||
"server": "example.test",
|
||||
"server_port": 51820,
|
||||
"local_address": ["10.7.0.2/32"],
|
||||
"peer_public_key": "peer",
|
||||
"mtu": 1408
|
||||
}
|
||||
""".trimIndent(),
|
||||
secretJson = """
|
||||
{
|
||||
"private_key": "secret",
|
||||
"pre_shared_key": "psk"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
|
||||
)
|
||||
|
||||
assertContains(result.configJson, "\"endpoints\"")
|
||||
assertContains(result.configJson, "\"address\": \"example.test\"")
|
||||
assertContains(result.configJson, "\"port\": 51820")
|
||||
assertContains(result.configJson, "\"address\": [")
|
||||
assertContains(result.configJson, "\"public_key\": \"peer\"")
|
||||
assertContains(result.configJson, "\"allowed_ips\": [")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateProfile_acceptsTypeSpecificConfigWithoutExplicitType() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "ss-main",
|
||||
name = "Shadowsocks",
|
||||
type = SingboxProxyProfileType.SHADOWSOCKS,
|
||||
configJson = "{\"server\":\"example.test\"}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
assertNull(builder.validateProfile(profile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_emitsLocalBootstrapAndDomainResolverForHostnames() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
bootstrapDohUrl = "https://dns.example/dns-query",
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(
|
||||
tag = "corp",
|
||||
address = "tls://dns.example",
|
||||
detourTag = "out-profile-1",
|
||||
matchInbounds = listOf("in-profile-1")
|
||||
)
|
||||
),
|
||||
finalServerTag = null,
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val config = JSONObject(result.configJson)
|
||||
val dns = config.getJSONObject("dns")
|
||||
val servers = dns.getJSONArray("servers")
|
||||
val local = servers.getJSONObject(0)
|
||||
val corp = servers.getJSONObject(1)
|
||||
val rule = dns.getJSONArray("rules").getJSONObject(0)
|
||||
|
||||
// `local` is always emitted; LocalDNSTransport bridge backs it at runtime.
|
||||
assertEquals("local", local.getString("type"))
|
||||
assertEquals("local", local.getString("tag"))
|
||||
assertFalse(local.has("detour"))
|
||||
|
||||
assertEquals("tls", corp.getString("type"))
|
||||
assertEquals("dns.example", corp.getString("server"))
|
||||
// Hostname target → bootstrap through `local`.
|
||||
assertEquals("local", corp.getString("domain_resolver"))
|
||||
assertEquals("out-profile-1", corp.getString("detour"))
|
||||
// The original `tls.server_name` plumbing is gone; SNI is derived
|
||||
// from the preserved hostname in `server`.
|
||||
assertFalse(corp.has("tls"))
|
||||
|
||||
assertEquals("route", rule.getString("action"))
|
||||
assertEquals("corp", rule.getString("server"))
|
||||
assertEquals("local", dns.getString("final"))
|
||||
|
||||
// route.default_domain_resolver ties WG peers and other outbound
|
||||
// hostnames into the same `local` bridge.
|
||||
val route = config.getJSONObject("route")
|
||||
assertEquals("local", route.getString("default_domain_resolver"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_omitsDomainResolverForIpLiteralServers() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
bootstrapDohUrl = "https://dns.example/dns-query",
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(
|
||||
tag = "plain",
|
||||
address = "udp://1.2.3.4",
|
||||
matchInbounds = emptyList()
|
||||
)
|
||||
),
|
||||
finalServerTag = "plain",
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val dns = JSONObject(result.configJson).getJSONObject("dns")
|
||||
// `local` is still emitted unconditionally as the bootstrap anchor.
|
||||
assertEquals("local", dns.getJSONArray("servers").getJSONObject(0).getString("type"))
|
||||
val plain = dns.getJSONArray("servers").getJSONObject(1)
|
||||
assertEquals("udp", plain.getString("type"))
|
||||
assertEquals("1.2.3.4", plain.getString("server"))
|
||||
// IP literal: no domain_resolver needed.
|
||||
assertFalse(plain.has("domain_resolver"))
|
||||
assertFalse(plain.has("tls"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildConfig_rejectsDnsConfigWithoutBootstrapDohUrl() {
|
||||
val builder = SingboxConfigBuilder()
|
||||
val profile = SingboxProxyProfile(
|
||||
id = "profile-1",
|
||||
name = "SOCKS",
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
val error = assertFailsWith<IllegalArgumentException> {
|
||||
builder.build(
|
||||
listOf(profile),
|
||||
SingboxProxyRuntimeOptions(
|
||||
preferredBasePort = 12500,
|
||||
blockUnmatchedTraffic = true,
|
||||
dnsConfig = SingboxProxyDnsConfig(
|
||||
servers = listOf(
|
||||
dnsServer(tag = "plain", address = "udp://1.2.3.4")
|
||||
),
|
||||
finalServerTag = "plain",
|
||||
domainStrategy = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"bootstrapDohUrl is required when dnsConfig is provided.",
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dnsServer(
|
||||
tag: String,
|
||||
address: String,
|
||||
detourTag: String? = null,
|
||||
matchDomainSuffixes: List<String> = emptyList(),
|
||||
matchInbounds: List<String> = emptyList()
|
||||
) = SingboxProxyDnsServerConfig(
|
||||
tag = tag,
|
||||
address = address,
|
||||
detourTag = detourTag,
|
||||
matchDomainSuffixes = matchDomainSuffixes,
|
||||
matchGeosites = emptyList(),
|
||||
matchOutbounds = emptyList(),
|
||||
matchInbounds = matchInbounds
|
||||
)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import android.content.Context
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
|
||||
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
internal class SingboxRuntimeManagerTest {
|
||||
@Test
|
||||
fun startFailure_preservesPreviouslyRunningEndpoints() {
|
||||
val runtime = FakeLibboxRuntime(failOnStartAttempt = 2)
|
||||
val manager = SingboxRuntimeManager(
|
||||
context = mock(Context::class.java),
|
||||
libboxRuntime = runtime,
|
||||
dispatchToMain = { action -> action() }
|
||||
)
|
||||
|
||||
val firstState = manager.awaitStart(listOf(profile(id = "profile-a"))).getOrThrow()
|
||||
val failedResult = manager.awaitStart(listOf(profile(id = "profile-b")))
|
||||
val stateAfterFailure = manager.getState()
|
||||
manager.close()
|
||||
|
||||
assertTrue(failedResult.isFailure)
|
||||
assertIs<IllegalStateException>(failedResult.exceptionOrNull())
|
||||
assertEquals(SingboxProxyRuntimeStatus.ERROR, stateAfterFailure.status)
|
||||
assertEquals(firstState.endpoints, stateAfterFailure.endpoints)
|
||||
assertEquals("start failed", stateAfterFailure.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun profile(id: String) = SingboxProxyProfile(
|
||||
id = id,
|
||||
name = id,
|
||||
type = SingboxProxyProfileType.SOCKS,
|
||||
configJson = """{"server":"127.0.0.1","server_port":1080}""",
|
||||
secretJson = null
|
||||
)
|
||||
|
||||
private fun SingboxRuntimeManager.awaitStart(
|
||||
profiles: List<SingboxProxyProfile>
|
||||
): Result<SingboxProxyRuntimeState> {
|
||||
val latch = CountDownLatch(1)
|
||||
var result: Result<SingboxProxyRuntimeState>? = null
|
||||
|
||||
start(profiles, SingboxProxyRuntimeOptions(preferredBasePort = 12080, blockUnmatchedTraffic = true)) {
|
||||
result = it
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for start callback")
|
||||
return result!!
|
||||
}
|
||||
|
||||
private class FakeLibboxRuntime(
|
||||
private val failOnStartAttempt: Int,
|
||||
) : LibboxRuntime(mock(Context::class.java)) {
|
||||
private var startAttempts = 0
|
||||
|
||||
override fun isAvailable(): Boolean = true
|
||||
|
||||
override fun start(configJson: String) {
|
||||
startAttempts += 1
|
||||
if (startAttempts == failOnStartAttempt) {
|
||||
throw IllegalStateException("start failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopService() {}
|
||||
|
||||
override fun close() {}
|
||||
|
||||
override fun setLogSink(sink: ((Int, String) -> Unit)?) {}
|
||||
|
||||
override fun setBootstrapDohUrl(url: String?) {}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package eu.weblibre.flutter_singbox_proxy
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Tag format is part of the Dart↔Kotlin contract: the Dart DNS resolver in
|
||||
* `dns_config_resolver.dart` emits `out-`/`in-` tags that must match what the
|
||||
* config builder writes. The mirrored Dart test lives in
|
||||
* `apps/weblibre/test/features/proxy/domain/services/singbox_tag_format_test.dart`
|
||||
* — both must update together if this format ever changes.
|
||||
*/
|
||||
internal class SingboxTagFormatTest {
|
||||
@Test
|
||||
fun outboundTag_isPrefixedAndSanitized() {
|
||||
assertEquals("out-singbox_foo-bar", SingboxTagFormat.outboundTag("singbox:foo-bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTag_isPrefixedAndSanitized() {
|
||||
assertEquals("in-singbox_foo-bar", SingboxTagFormat.inboundTag("singbox:foo-bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeTag_preservesAlphanumericDotsDashesUnderscores() {
|
||||
assertEquals(
|
||||
"Abc_123.x-Y",
|
||||
SingboxTagFormat.sanitizeTag("Abc_123.x-Y")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeTag_replacesSpacesSlashesAndColons() {
|
||||
assertEquals(
|
||||
"a_b_c_d_e",
|
||||
SingboxTagFormat.sanitizeTag("a:b c/d\\e")
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user