From d021fa9981e5c8566e3169d29dbac59b40f149fe Mon Sep 17 00:00:00 2001 From: SNW Date: Wed, 23 Sep 2026 09:53:07 +0000 Subject: [PATCH] Integrate sing-box-lx libbox on Android --- README.md | 19 +-- THIRD_PARTY_NOTICES.md | 15 +- app/core/native.py | 27 ++-- app/screens/vpn.py | 2 +- docs/architecture.md | 20 +-- docs/building.md | 17 ++- docs/implementation-status.md | 33 +++-- docs/upstream-research.md | 23 ++- native/android/README.md | 23 ++- .../su/snw/vpn_native_bridge/EngineAdapter.kt | 133 ++++++++++++++++-- .../su/snw/vpn_native_bridge/RuntimeStore.kt | 5 +- .../su/snw/vpn_native_bridge/SignBoxConfig.kt | 108 ++++++++++++++ .../snw/vpn_native_bridge/SignBoxPlatform.kt | 112 +++++++++++++++ .../vpn_native_bridge/VpnRuntimeService.kt | 37 +---- native/android/libbox.version | 1 + native/android/libs/README.md | 10 +- scripts/build-android.sh | 26 ++-- scripts/fetch-engine-artifacts.sh | 21 ++- tests/test_native_backend.py | 66 +++++++++ 19 files changed, 561 insertions(+), 137 deletions(-) create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxConfig.kt create mode 100644 native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxPlatform.kt create mode 100644 native/android/libbox.version create mode 100644 tests/test_native_backend.py diff --git a/README.md b/README.md index 85a378c..290982e 100644 --- a/README.md +++ b/README.md @@ -55,25 +55,28 @@ User-Agent strings are not authentication. ## Build scripts ```bash -./scripts/fetch-engine-artifacts.sh # optional, requires upstream build tools +./scripts/fetch-engine-artifacts.sh # fetches pinned libbox + verifies SHA256SUMS ./scripts/build-android.sh # flet build apk (requires Android SDK/Flutter) ./scripts/build-desktop.sh linux # flet build linux ./scripts/run-backend.sh # local backend ``` -For Android, `./scripts/build-android.sh` builds an arm64 APK with any reviewed artifacts -present locally; missing engines report `UNSUPPORTED` at runtime. For a releasable build, -place the exact `freeturn.aar` and compatible `libbox.aar` under -`native/android/libs/` (the directory is ignored to avoid silently shipping unreviewed -native code) and set `REQUIRE_NATIVE_ARTIFACTS=1`. +For Android, the fetch script downloads `Leadaxe/sing-box-lx` at the pinned version. +`REQUIRE_NATIVE_ARTIFACTS=1 ./scripts/build-android.sh` then builds an arm64 APK. +Artifacts stay ignored; versions and hashes live in `native/android/libbox.version` and +`THIRD_PARTY_NOTICES.md`. + +The current Android APK is libbox-first. FreeTurn is not packaged alongside it because +the independently generated gomobile AARs contain incompatible global runtime classes; +see `docs/implementation-status.md`. ## Security posture The default proxy bind is localhost. Wi-Fi sharing is explicit, binds to the discovered LocalOnlyHotspot address, and requires authentication by default. Management/debug APIs are disabled by default. Secrets are redacted from logs. Application routing models -distinguish `PROXY`, `DIRECT`, and `VOID`; native UID matching and fail-closed `VOID` -enforcement are not yet shipped and are never presented as active security. +distinguish `PROXY`, `DIRECT`, and `VOID`; DIRECT excludes the package from VpnService, +while VOID fails startup until its native fail-closed classifier is shipped. ## License and upstreams diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3d6460f..d5c8ec6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,19 +1,18 @@ # Third-party notices This file is intentionally explicit about components that may be bundled. Native -artifacts are not committed to this repository; the exact version and SHA-256 must be -recorded here before a release artifact is built. +artifacts are not committed to this repository; their version and SHA-256 are pinned +here and in the fetch script. | Component | Use | Version/commit | License | Source | | --- | --- | --- | --- | --- | | Flet | Python UI/runtime packaging | pinned by `uv.lock` | Apache-2.0 | https://github.com/flet-dev/flet | -| FreeTurn mobile API | Android FreeTurn allowlist/whitelist transport | `samosvalishe/free-turn-proxy` commit `065385e656085a06bf8079958bf1b563901db64f`; local AAR SHA-256 `112207f15bd5c93500be13e249f884dec0768d0f542599b3e24f641837483a6e` | GPL-3.0 | https://github.com/samosvalishe/free-turn-proxy | +| FreeTurn mobile API | Future Android FreeTurn transport; currently excluded because its gomobile runtime conflicts with libbox | `samosvalishe/free-turn-proxy` commit `065385e656085a06bf8079958bf1b563901db64f`; reviewed AAR SHA-256 `112207f15bd5c93500be13e249f884dec0768d0f542599b3e24f641837483a6e` | GPL-3.0 | https://github.com/samosvalishe/free-turn-proxy | | FreeTurn Android reference | lifecycle/API reference only; not a source dependency | inspected only | GPL-3.0 | https://github.com/samosvalishe/turn-proxy-android | -| sign-box/libbox or private SignBox fork | VPN/proxy engine artifact, if supplied | **must be pinned before release** | check supplied artifact/source | https://github.com/SagerNet/sing-box | +| sing-box-lx / libbox | Android VLESS + AmneziaWG 3.x VPN/proxy engine | `v1.14.1-lx.8`, tag commit `8e12ec7db6a77130fcc63b7c545d3a41f895f30a`; AAR SHA-256 `beab998c9d0a46826db0d654b5315fa6a25d9e1cbf8cb10c547e757444c26d80` | GPL-3.0-or-later with additional naming restriction in upstream LICENSE | https://github.com/Leadaxe/sing-box-lx | | XTLS/libXray | optional future VLESS mobile binding; not currently bundled | **not bundled** | MPL-2.0 | https://github.com/XTLS/libXray | | FastAPI / Uvicorn / Pydantic / qrcode | Python app/backend dependencies | pinned by `uv.lock` | respective upstream licenses | PyPI | -The Android reference application is not copied. Its GPL license does not automatically -apply to an independent implementation that only uses documented APIs, but distributing -the FreeTurn GPL mobile library still requires complying with its license and providing -corresponding notices/source obligations as applicable. +The Android reference applications are not copied. Distribution of an APK containing +`libbox.so` must comply with sing-box-lx's GPL and additional LICENSE term; the MIT +license applies only to original code and does not relicense the combined binary. diff --git a/app/core/native.py b/app/core/native.py index cba406c..60c5eea 100644 --- a/app/core/native.py +++ b/app/core/native.py @@ -35,16 +35,18 @@ class NativeEngineBackend(EngineBackend): def _capabilities(kind: EngineKind) -> CapabilitySet: if kind is EngineKind.FREETURN: return CapabilitySet( - supports_proxy=True, - supports_socks=False, - supports_udp=True, supports_mobile=True, - supports_stats=True, ) return CapabilitySet( - # The exact SignBox AAR/API is a release input. Keeping these false - # prevents the UI from advertising a native path that is not bundled. + supports_vpn=True, + supports_proxy=True, + supports_socks=True, + supports_http=True, + supports_udp=True, supports_mobile=True, + supports_stats=False, + supports_hot_reload=False, + supports_both=True, ) async def start(self, request: RuntimeRequest) -> RuntimeSnapshot: @@ -69,6 +71,11 @@ class NativeEngineBackend(EngineBackend): self._state = self._snapshot_from_result(existing, request, existing_state) return self._state self._request = request + if request.mode is RunMode.BOTH and request.proxy.access.value == "hotspot": + raise UnsupportedEngineError( + "Both + LocalOnlyHotspot needs single-core service coordination; " + "local Both and hotspot Proxy are available" + ) if request.mode is RunMode.PROXY: result = ( await self.bridge.start_proxy(payload) @@ -77,14 +84,6 @@ class NativeEngineBackend(EngineBackend): ) else: result = await self.bridge.start_vpn(payload) - if request.mode is RunMode.BOTH and self._result_state(result) not in { - EngineState.ERROR, - EngineState.UNSUPPORTED, - }: - if request.proxy.access.value == "hotspot": - await self.bridge.start_hotspot_proxy(payload) - else: - await self.bridge.start_proxy(payload) self._state = self._snapshot_from_result(result, request, EngineState.STARTING) return self._raise_if_terminal_error(self._state) diff --git a/app/screens/vpn.py b/app/screens/vpn.py index cbf947e..7ae92c9 100644 --- a/app/screens/vpn.py +++ b/app/screens/vpn.py @@ -162,7 +162,7 @@ def build_vpn_screen( ), *([ ft.Text( - "SignBox native artifact/API ещё не подключён; подключение недоступно.", + "Нативный SignBox runtime недоступен в этой сборке.", color=ft.Colors.ERROR, ) ] if not engine_ready else []), diff --git a/docs/architecture.md b/docs/architecture.md index ebfdebe..7d44bd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,8 +38,8 @@ FreeTurn desktop is explicitly unsupported. Android state is authoritative in native `SharedPreferences`. Reopening the Flet UI must call `get_runtime_state`; missing Python state never implies that a service is stopped. -The current native bridge already has this contract, but the engine AAR API still must -be pinned before the adapter can report `RUNNING`. +The native bridge has this contract. `sing-box-lx` is pinned and runs in-process; the +service owns `CommandServer`, while `CommandClient` is its local management channel. ## Engine abstraction @@ -50,9 +50,9 @@ are rendered as `Недоступно`, not as connected. Internals remain different: -- Once the exact native artifact is supplied, SignBox can expose VLESS and AmneziaWG - packet/TUN plus local SOCKS/HTTP. The current adapter deliberately reports - `UNSUPPORTED` until that API is pinned and implemented. +- SignBox exposes VLESS and AmneziaWG packet/TUN plus sing-box SOCKS/HTTP inbounds. + `PlatformInterface.openTun` is the only creator of the Android TUN; Python and the + Activity never own its fd. - FreeTurn uses its own mobile binding and event/CAPTCHA lifecycle. Its `Start` API is not a generic SOCKS5/HTTP listener; standard sharing remains unsupported until a separately implemented listener reports actual bound addresses. @@ -62,8 +62,10 @@ Internals remain different: ## Modes and sharing -Desktop is proxy-only in the first release. Android supports `TUN`, `Proxy`, and -`Both` only when the selected native artifact advertises the matching capability. +Desktop is proxy-only in the first release. Android supports VPN, proxy-only, and local +`Both` with one sing-box instance. Hotspot proxy-only is implemented. `Both + +LocalOnlyHotspot` remains explicitly unsupported until hotspot discovery and +VpnService startup are coordinated around the same core. Proxy defaults to `127.0.0.1`; Wi-Fi sharing uses `LocalOnlyHotspot`, discovers its actual interface address, and binds listeners only after the hotspot callback. It never @@ -96,7 +98,7 @@ settings screen can grow without a universal nullable profile. ## Deliberate non-goals - iOS, Personal Hotspot automation and iOS per-app routing. -- Android application routing (`DIRECT`/`VOID`) until native UID ownership and the - fail-closed packet drop path are implemented and tested on real devices. +- `DIRECT` uses Android package exclusion. `VOID` remains unavailable until native UID + ownership and the fail-closed packet drop path are implemented and device-tested. - bundled native cores without pinned source, license, ABI and API evidence. - management/debug listeners exposed by SignBox. diff --git a/docs/building.md b/docs/building.md index 4b29d42..bf0a308 100644 --- a/docs/building.md +++ b/docs/building.md @@ -30,8 +30,8 @@ the admin token in a public URL. ## Android -The Flet shell can be built without native core artifacts; runtime connections then -return `UNSUPPORTED` honestly. A release build can enforce both artifacts: +The Flet shell can be built without a native core artifact; runtime connections then +return `UNSUPPORTED` honestly. A release build pins and enforces libbox: ```bash ./scripts/fetch-engine-artifacts.sh @@ -54,11 +54,14 @@ task build:android cp dist/freeturn.aar /path/to/repo/native/android/libs/ ``` -For SignBox, use the exact artifact that exposes the API expected by -`native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/.../EngineAdapter.kt`. A generic sing-box -`libbox.aar` is not silently assumed to be the user's private SignBox fork. Record its -commit, build tags, ABIs, license and SHA-256 in `THIRD_PARTY_NOTICES.md`, then replace -the adapter with calls from that artifact and add a native integration test. +`scripts/fetch-engine-artifacts.sh` downloads `Leadaxe/sing-box-lx` at the version in +`native/android/libbox.version`, verifies release `SHA256SUMS`, and installs it locally. +To upgrade, change that version, run the fetch script, inspect the AAR API with `javap`, +update `THIRD_PARTY_NOTICES.md`, rebuild, and execute the Android manual test matrix. + +The FreeTurn AAR is intentionally not packaged in the same APK yet: both artifacts own +different global gomobile runtime classes. It requires a namespaced binding or another +device-proven isolation solution, not a duplicate-class exclusion workaround. ## Desktop diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 5f2ad06..58f5dbf 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -4,25 +4,29 @@ - Flet web shell starts; Linux desktop bundle builds. - ARM64 Android APK builds with current Flet/Gradle toolchain. -- FreeTurn `freeturn.aar` from the pinned upstream commit is embedded as - `classes.jar + jniLibs`; Kotlin reflection calls the documented mobile API, - installs `EventSink`/`Protector`, and keeps its state in the foreground service. +- `sing-box-lx v1.14.1-lx.8` is fetched with upstream checksum verification and + packaged as `libbox.so`; the ARM64 APK contains the real native engine. +- Android SignBox starts in-process through `CommandServer`, opens its TUN through + `PlatformInterface`, and creates SOCKS5/HTTP listeners through sing-box inbounds. +- VPN, proxy-only, and local Both share the same configuration path; proxy-only does + not call `VpnService` or create a TUN. - FastAPI backend, one-file admin UI, encrypted-at-rest secrets, device enrollment, User-Agent binding and subscription response pass tests and a local HTTP smoke test. - Python lifecycle, conflict rules, serialization, billing date, proxy settings, enrollment URL and port-selection tests pass. -## Deliberately unavailable until the missing native work is supplied +## Deliberately unavailable / device acceptance pending -- SignBox VLESS/AmneziaWG 3.1: the exact private AAR/API was not supplied. The - native adapter and Android VPN modes report `UNSUPPORTED`; no generic sing-box - artifact is guessed. -- Android SOCKS5/HTTP listener, Wi-Fi QR sharing and `Both`: the current adapter - does not report actual bound listeners, so hotspot sharing fails closed instead - of showing ports that do not exist. -- `DIRECT`/`VOID` application routing: models and UI boundary exist, but UID - ownership, `addDisallowedApplication` for DIRECT and native fail-closed packet - dropping for VOID are not implemented. +- A physical-device run with production VLESS and AmneziaWG profiles is still required; + a successful compile does not prove remote connectivity. +- Proxy-only LocalOnlyHotspot starts listeners on the discovered address. `Both + + hotspot` is explicitly rejected until one-core startup is coordinated across hotspot + discovery and `VpnService`. +- `DIRECT` is applied with package exclusion. `VOID` rejects startup as unsupported; + the fail-closed UID-aware packet classifier is not implemented. +- FreeTurn's reviewed AAR cannot coexist with libbox yet because both ship incompatible + global gomobile runtime classes. The libbox-first APK excludes FreeTurn rather than + risking JNI crashes. - Backend sync is a reusable client contract with injected secret storage; wiring it to Android Keystore, an enrollment screen and automatic profile refresh remains. @@ -31,8 +35,7 @@ build/integration milestone; it is not evidence that the missing data path works ## Device acceptance still required -Use `docs/android-manual-tests.md` on a physical Android API 26+ device after the -SignBox artifact and listener implementation are added. In particular test VPN +Use `docs/android-manual-tests.md` on a physical Android API 26+ device. In particular test VPN consent, screen lock/UI death, service conflict, backend crash, hotspot permissions, actual interface address, laptop SOCKS/HTTP traffic and the aggressive VPN-detection VOID case. diff --git a/docs/upstream-research.md b/docs/upstream-research.md index cda63f3..d09e8c5 100644 --- a/docs/upstream-research.md +++ b/docs/upstream-research.md @@ -1,6 +1,6 @@ # Upstream/API research snapshot -Checked 2026-09-22. +Checked 2026-09-23. ## Flet @@ -36,13 +36,22 @@ The current `samosvalishe/free-turn-proxy` mobile README documents the gomobile `OnCaptcha`. The Android AAR is built with the upstream `task build:android` target. The Android reference app is GPL-3.0 and is used only as lifecycle/API evidence. -## SignBox / sing-box +## SignBox / sing-box-lx -The exact public name `signbox` and a public artifact matching the user's VLESS + -AmneziaWG 3.1 claim were not found. Upstream sing-box exposes `libbox.aar` through -gomobile, but its package/API and build tags must be checked against the actual SignBox -artifact before compiling calls. The repo therefore has a real adapter boundary and a -hard `UNSUPPORTED` result rather than a fabricated success. +The selected fork is `Leadaxe/sing-box-lx`, pinned to `v1.14.1-lx.8`. Its release AAR +is verified against upstream `SHA256SUMS`; it exposes `CommandServer`, `CommandClient`, +`PlatformInterface`, VLESS and AmneziaWG 3.x features. LxBox confirms Android lifecycle +uses `CommandServer` and manages the live core through an in-process `CommandClient`, +without a public management port. + +The fork registers `tun`, `mixed`, `socks`, and `http` inbounds. Because this UI exposes +separate ports, the client emits separate SOCKS and HTTP inbounds instead of forcing +both protocols onto one mixed port. + +FreeTurn and libbox AARs ship different `go.Seq`/`go.Universe` implementations. Android +rejects the duplicate classes, and sharing one version across independently built JNI +libraries is unsafe. The libbox-first APK excludes FreeTurn until namespace isolation +or another proven isolation design is implemented. ## Removed scope diff --git a/native/android/README.md b/native/android/README.md index 311a879..997a067 100644 --- a/native/android/README.md +++ b/native/android/README.md @@ -13,28 +13,27 @@ Python/Flet -> FletService -> MethodChannel -> Kotlin bridge -> FreeTurn/libbox adapters ``` -`VpnRuntimeService` calls `VpnService.prepare()` through the bridge, starts foreground -immediately, configures a predictable `10.0.0.1/24` TUN, and persists state in -`SharedPreferences`. The current adapter refuses to report success unless the expected -native engine classes are bundled. This avoids a fake VPN that installs a black-hole -interface. +`VpnRuntimeService` calls `VpnService.prepare()` through the bridge and starts foreground +immediately. The pinned libbox core asks `PlatformInterface.openTun()` to establish the +TUN from its effective config; this prevents a fake Connected state with an unowned +black-hole interface. Runtime state is persisted in `SharedPreferences`. ## Native artifacts Put reviewable, pinned artifacts in `libs/` locally: -- `freeturn.aar` from the FreeTurn `mobile` package. -- `libbox.aar` or the exact private SignBox AAR with VLESS + AmneziaWG 3.1 support. +- `libbox.aar` from `Leadaxe/sing-box-lx`, version pinned in `libbox.version`. +- `freeturn.aar` may be kept for review, but is not packaged with libbox yet because + their independently generated gomobile runtimes use conflicting global classes. The files are git-ignored. Record version, commit and SHA-256 in `THIRD_PARTY_NOTICES.md` before distributing an APK. `scripts/fetch-engine-artifacts.sh` -prints the upstream build commands and checks for the files; it does not silently -download an unpinned binary. +downloads the pinned libbox release and verifies its upstream `SHA256SUMS`. -`scripts/build-android.sh` extracts each present AAR's `classes.jar` and `jni/*` into +`scripts/build-android.sh` extracts libbox's `classes.jar` and `jni/*` into the packaged Flutter plugin. This is required because Gradle does not allow a direct local `.aar` dependency while producing the plugin AAR. A missing artifact does not make a smoke APK pretend to work: the corresponding adapter returns `UNSUPPORTED`. -The first native milestone still needs device testing for VPN consent, foreground -service timing, packet forwarding, hotspot interface discovery, and engine ABI/API. +The native milestone still needs physical-device testing for VPN consent, foreground +service timing, packet forwarding, hotspot interface discovery, VLESS and AmneziaWG. diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt index 43175ba..61e4bbf 100644 --- a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/EngineAdapter.kt @@ -1,6 +1,23 @@ package su.snw.vpn_native_bridge import android.content.Context +import android.net.VpnService +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.CommandClientHandler +import io.nekohasekai.libbox.CommandClientOptions +import io.nekohasekai.libbox.CommandServer +import io.nekohasekai.libbox.CommandServerHandler +import io.nekohasekai.libbox.ConnectionEvents +import io.nekohasekai.libbox.DnsQuery +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.LogIterator +import io.nekohasekai.libbox.OutboundGroupItemIterator +import io.nekohasekai.libbox.OutboundGroupIterator +import io.nekohasekai.libbox.OverrideOptions +import io.nekohasekai.libbox.SetupOptions +import io.nekohasekai.libbox.StatusMessage +import io.nekohasekai.libbox.StringIterator +import io.nekohasekai.libbox.SystemProxyStatus import org.json.JSONObject import java.lang.reflect.InvocationTargetException import java.lang.reflect.Proxy @@ -16,17 +33,117 @@ internal interface EngineAdapter { fun stop(): AdapterResult } -/** - * The libbox API is intentionally isolated here. The exact AAR/signbox package is a - * release input, not an invented compile-time API. Until it is pinned, refusing to - * start is safer than showing a connected TUN with no packet engine behind it. - */ -internal class SignBoxAdapter : EngineAdapter { +/** Owns one in-process sing-box-lx service. No management/debug TCP port is opened. */ +internal class SignBoxAdapter : EngineAdapter, CommandServerHandler { + private var server: CommandServer? = null + private var client: CommandClient? = null + private var platform: SignBoxPlatform? = null + override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult { - return AdapterResult(false, "SignBox/libbox AAR is not bundled or its API is not pinned") + return try { + val request = JSONObject(requestJson) + SignBoxSetup.initialize(context) + val built = SignBoxConfig.build(request) + Libbox.checkConfig(built.json) + val vpnService = context as? VpnService + if (built.needsTun && vpnService == null) { + return AdapterResult(false, "TUN mode must run inside Android VpnService") + } + platform = SignBoxPlatform(context, vpnService) + val commandServer = CommandServer(this, platform) + commandServer.start() + server = commandServer + commandServer.startOrReloadService(built.json, buildOverrides(request)) + + // CommandClient is the in-process management path shipped by sing-box-lx. + val commandClient = CommandClient(NoopCommandClientHandler, CommandClientOptions()) + commandClient.connect() + client = commandClient + AdapterResult(true, proxyAddresses = built.proxyAddresses) + } catch (error: Throwable) { + stop() + AdapterResult(false, "sing-box-lx startup failed: ${error.message ?: error.javaClass.simpleName}") + } } - override fun stop(): AdapterResult = AdapterResult(true) + override fun stop(): AdapterResult { + val failures = mutableListOf() + client?.let { runCatching { it.disconnect() }.onFailure { e -> failures += e.message ?: "client disconnect" } } + client = null + server?.let { commandServer -> + runCatching { commandServer.closeService() }.onFailure { e -> failures += e.message ?: "service close" } + runCatching { commandServer.close() }.onFailure { e -> failures += e.message ?: "server close" } + } + server = null + platform?.closeTun() + platform = null + return AdapterResult(failures.isEmpty(), failures.joinToString("; ").ifBlank { null }) + } + + private fun buildOverrides(request: JSONObject): OverrideOptions { + val options = OverrideOptions() + val rules = request.optJSONObject("vpn")?.optJSONObject("app_rules") ?: return options + val direct = mutableListOf() + rules.keys().forEach { packageName -> + if (rules.optString(packageName) == "direct") direct += packageName + } + if (direct.isNotEmpty()) options.excludePackage = StringList(direct) + return options + } + + override fun serviceReload() {} + override fun serviceStop() { Thread { stop() }.start() } + override fun getSystemProxyStatus(): SystemProxyStatus = SystemProxyStatus() + override fun setSystemProxyEnabled(isEnabled: Boolean) {} + override fun connectSSHAgent(): Int = throw UnsupportedOperationException("SSH agent is disabled") + override fun triggerNativeCrash() {} + override fun writeDebugMessage(message: String) { + android.util.Log.d("VpnApp/libbox", SensitiveLog.sanitize(message)) + } +} + +private object SignBoxSetup { + @Volatile private var initialized = false + + @Synchronized + fun initialize(context: Context) { + if (initialized) return + val options = SetupOptions().apply { + basePath = context.filesDir.resolve("sing-box").also { it.mkdirs() }.absolutePath + workingPath = basePath + tempPath = context.cacheDir.resolve("sing-box").also { it.mkdirs() }.absolutePath + fixAndroidStack = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P + logMaxLines = 1000 + debug = false + oomKillerEnabled = true + crashReportSource = "vpnapp" + } + Libbox.setup(options) + initialized = true + } +} + +internal class StringList(values: List) : StringIterator { + private val values = values.toList() + private var index = 0 + override fun len(): Int = values.size + override fun hasNext(): Boolean = index < values.size + override fun next(): String = if (hasNext()) values[index++] else "" +} + +private object NoopCommandClientHandler : CommandClientHandler { + override fun connected() {} + override fun disconnected(message: String?) {} + override fun clearLogs() {} + override fun setDefaultLogLevel(level: Int) {} + override fun initializeClashMode(modeList: StringIterator?, currentMode: String?) {} + override fun updateClashMode(newMode: String?) {} + override fun writeLogs(messageList: LogIterator?) {} + override fun writeStatus(message: StatusMessage?) {} + override fun writeGroups(groups: OutboundGroupIterator?) {} + override fun writeOutbounds(outbounds: OutboundGroupItemIterator?) {} + override fun writeConnectionEvents(message: ConnectionEvents?) {} + override fun writeDNSQuery(query: DnsQuery?) {} } internal class FreeTurnAdapter : EngineAdapter { diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt index a87c736..f9ff78c 100644 --- a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/RuntimeStore.kt @@ -89,7 +89,10 @@ internal class RuntimeStore(context: Context) { val message = components.firstOrNull { it.optString("state") in setOf("error", "unsupported") }?.optString("message", null) - val payload = proxy.optJSONObject("payload") ?: JSONObject() + val payload = JSONObject(vpn.optJSONObject("payload")?.toString() ?: "{}") + proxy.optJSONObject("payload")?.let { proxyPayload -> + proxyPayload.keys().forEach { key -> payload.put(key, proxyPayload.get(key)) } + } return JSONObject() .put("state", state) .put("message", message) diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxConfig.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxConfig.kt new file mode 100644 index 0000000..ffabe57 --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxConfig.kt @@ -0,0 +1,108 @@ +package su.snw.vpn_native_bridge + +import io.nekohasekai.libbox.Libbox +import org.json.JSONArray +import org.json.JSONObject + +internal data class BuiltSignBoxConfig( + val json: String, + val needsTun: Boolean, + val proxyAddresses: List, +) + +/** Adds only app-owned inbounds to an otherwise complete sing-box profile. */ +internal object SignBoxConfig { + fun build(request: JSONObject): BuiltSignBoxConfig { + val mode = request.optString("mode", "vpn") + val config = JSONObject(request.optJSONObject("engine_config")?.toString() ?: "{}") + listOf("strategy", "mtu", "split_tunneling", "raw_config").forEach(config::remove) + val inbounds = config.optJSONArray("inbounds") ?: JSONArray().also { config.put("inbounds", it) } + val needsTun = mode == "vpn" || mode == "both" + if (needsTun && !containsType(inbounds, "tun")) inbounds.put(tunInbound(request)) + + val rules = request.optJSONObject("vpn")?.optJSONObject("app_rules") + if (rules != null && rules.keys().asSequence().any { rules.optString(it) == "void" }) { + throw UnsupportedOperationException( + "VOID is fail-closed and is not enabled until the packet classifier is installed", + ) + } + + val addresses = if (mode == "proxy" || mode == "both") addProxyInbounds(request, inbounds) else emptyList() + if (!config.has("outbounds") && !config.has("endpoints")) { + throw IllegalArgumentException("profile must contain sing-box outbounds or endpoints") + } + return BuiltSignBoxConfig(config.toString(), needsTun, addresses) + } + + private fun tunInbound(request: JSONObject): JSONObject { + val vpn = request.optJSONObject("vpn") ?: JSONObject() + val address = vpn.optString("tun_address", "10.0.0.1") + val prefix = vpn.optInt("tun_prefix", 24) + val tun = JSONObject() + .put("type", "tun") + .put("tag", "vpnapp-tun") + .put("interface_name", "vpnapp") + .put("address", JSONArray().put("$address/$prefix")) + .put("mtu", vpn.optInt("mtu", 1280)) + .put("auto_route", true) + .put("strict_route", true) + .put("stack", "mixed") + val direct = JSONArray() + vpn.optJSONObject("app_rules")?.let { rules -> + rules.keys().forEach { packageName -> + if (rules.optString(packageName) == "direct") direct.put(packageName) + } + } + if (direct.length() > 0) tun.put("exclude_package", direct) + return tun + } + + private fun addProxyInbounds(request: JSONObject, inbounds: JSONArray): List { + val proxy = request.optJSONObject("proxy") ?: JSONObject() + val bind = proxy.optString("bind_address", "127.0.0.1") + require(bind != "0.0.0.0" && bind != "::") { "wildcard proxy listeners require an explicit advanced opt-in" } + val users = if (proxy.optBoolean("authentication", false)) { + JSONArray().put( + JSONObject() + .put("username", proxy.getString("username")) + .put("password", proxy.getString("password")), + ) + } else null + val result = mutableListOf() + var nextRandomPort = 49152 + fun portFor(name: String): Int { + if (!proxy.optBoolean("${name}_random_port", false)) return proxy.getInt("${name}_port") + val selected = Libbox.availablePort(nextRandomPort) + nextRandomPort = selected + 1 + return selected + } + if (proxy.optBoolean("socks_enabled", true)) { + val port = portFor("socks") + inbounds.put(listener("socks", "vpnapp-socks", bind, port, users)) + result += "socks5://$bind:$port" + } + if (proxy.optBoolean("http_enabled", true)) { + val port = portFor("http") + inbounds.put(listener("http", "vpnapp-http", bind, port, users)) + result += "http://$bind:$port" + } + require(result.isNotEmpty()) { "at least one proxy protocol must be enabled" } + return result + } + + private fun listener( + type: String, + tag: String, + bind: String, + port: Int, + users: JSONArray?, + ): JSONObject = JSONObject() + .put("type", type) + .put("tag", tag) + .put("listen", bind) + .put("listen_port", port) + .also { if (users != null) it.put("users", users) } + + private fun containsType(inbounds: JSONArray, type: String): Boolean = + (0 until inbounds.length()).any { inbounds.optJSONObject(it)?.optString("type") == type } +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxPlatform.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxPlatform.kt new file mode 100644 index 0000000..4dabfaf --- /dev/null +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/SignBoxPlatform.kt @@ -0,0 +1,112 @@ +package su.snw.vpn_native_bridge + +import android.content.Context +import android.content.pm.PackageManager.NameNotFoundException +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.Process +import io.nekohasekai.libbox.* +import java.net.InetSocketAddress + +/** Minimal public-API Android platform implementation required by libbox. */ +internal class SignBoxPlatform( + private val context: Context, + private val vpnService: VpnService?, +) : PlatformInterface { + private var tun: ParcelFileDescriptor? = null + + override fun autoDetectInterfaceControl(fd: Int) { vpnService?.protect(fd) } + override fun usePlatformAutoDetectInterfaceControl(): Boolean = vpnService != null + override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + + override fun openTun(options: TunOptions): Int { + val service = vpnService ?: error("TUN requested outside VpnService") + if (VpnService.prepare(service) != null) error("VPN permission is missing") + val builder = service.Builder().setSession("VPN App / sing-box-lx").setMtu(options.mtu) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false) + copyPrefixes(options.inet4Address) { address, prefix -> builder.addAddress(address, prefix) } + copyPrefixes(options.inet6Address) { address, prefix -> builder.addAddress(address, prefix) } + if (options.autoRoute) { + val dns = options.dnsServerAddress + while (dns.hasNext()) dns.next().takeIf(String::isNotBlank)?.let(builder::addDnsServer) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + copyPrefixes(options.inet4RouteAddress) { address, prefix -> builder.addRoute(address, prefix) } + copyPrefixes(options.inet6RouteAddress) { address, prefix -> builder.addRoute(address, prefix) } + copyPrefixes(options.inet4RouteExcludeAddress) { address, prefix -> + builder.excludeRoute(android.net.IpPrefix(java.net.InetAddress.getByName(address), prefix)) + } + copyPrefixes(options.inet6RouteExcludeAddress) { address, prefix -> + builder.excludeRoute(android.net.IpPrefix(java.net.InetAddress.getByName(address), prefix)) + } + } else { + copyPrefixes(options.inet4RouteRange) { address, prefix -> builder.addRoute(address, prefix) } + copyPrefixes(options.inet6RouteRange) { address, prefix -> builder.addRoute(address, prefix) } + } + copyPackages(options.includePackage) { builder.addAllowedApplication(it) } + copyPackages(options.excludePackage) { builder.addDisallowedApplication(it) } + } + val created = builder.establish() ?: error("Android did not establish the VPN interface") + tun?.close() + tun = created + return created.fd + } + + fun closeTun() { runCatching { tun?.close() }; tun = null } + + private fun copyPrefixes(iterator: RoutePrefixIterator, block: (String, Int) -> Unit) { + while (iterator.hasNext()) iterator.next().let { block(it.address(), it.prefix()) } + } + + private fun copyPackages(iterator: StringIterator, block: (String) -> Unit) { + while (iterator.hasNext()) try { block(iterator.next()) } catch (_: NameNotFoundException) {} + } + + override fun findConnectionOwner(protocol: Int, sourceAddress: String, sourcePort: Int, destinationAddress: String, destinationPort: Int): ConnectionOwner { + val owner = ConnectionOwner() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return owner.apply { userId = Process.INVALID_UID } + val uid = runCatching { + val cm = context.getSystemService(android.net.ConnectivityManager::class.java) + cm.getConnectionOwnerUid(protocol, InetSocketAddress(sourceAddress, sourcePort), InetSocketAddress(destinationAddress, destinationPort)) + }.getOrDefault(Process.INVALID_UID) + owner.userId = uid + if (uid != Process.INVALID_UID) { + val packages = context.packageManager.getPackagesForUid(uid)?.toList().orEmpty() + owner.userName = packages.firstOrNull().orEmpty() + owner.setAndroidPackageNames(StringList(packages)) + } + return owner + } + + override fun getInterfaces(): NetworkInterfaceIterator = object : NetworkInterfaceIterator { + override fun hasNext() = false + override fun next() = io.nekohasekai.libbox.NetworkInterface() + } + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {} + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {} + override fun localDNSTransport(): LocalDNSTransport? = null + override fun readWIFIState(): WIFIState? = null + override fun underNetworkExtension() = false + override fun includeAllNetworks() = false + override fun clearDNSCache() {} + override fun registerMyInterface(name: String) {} + override fun usePlatformShell() = false + override fun checkPlatformShell() {} + override fun lookupUser(username: String) = PlatformUser() + override fun lookupSFTPServer() = "" + override fun readSystemSSHHostKey() = "" + override fun tailscaleHostname() = "" + override fun openShellSession(user: PlatformUser, command: String, env: StringIterator, termType: String, width: Int, height: Int): ShellSession = + throw UnsupportedOperationException("platform shell is disabled") + override fun startNeighborMonitor(listener: NeighborUpdateListener) {} + override fun closeNeighborMonitor(listener: NeighborUpdateListener) {} + override fun usePlatformBridge() = false + override fun createBridge(options: BridgeOptions): BridgeSession = throw UnsupportedOperationException("platform bridge is disabled") + override fun sendNotification(notification: io.nekohasekai.libbox.Notification) {} + override fun cancelNotification(identifier: String, typeID: Int) {} +} + +internal object SensitiveLog { + private val secrets = Regex("(?i)(password|token|private_key|uuid)\\s*[:=]\\s*[^,\\s]+") + fun sanitize(value: String): String = value.replace(secrets, "$1=") +} diff --git a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt index 7ee742c..b2f23f3 100644 --- a/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt +++ b/native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/src/main/kotlin/su/snw/vpn_native_bridge/VpnRuntimeService.kt @@ -7,12 +7,10 @@ import android.content.Intent import android.net.VpnService import android.os.Build import android.os.IBinder -import android.os.ParcelFileDescriptor import java.util.concurrent.Executors class VpnRuntimeService : VpnService() { private lateinit var store: RuntimeStore - private var tun: ParcelFileDescriptor? = null private var adapter: EngineAdapter? = null private var intentRequest: String = "{}" private val worker = Executors.newSingleThreadExecutor() @@ -45,45 +43,24 @@ class VpnRuntimeService : VpnService() { return } store.write("starting", request = input) - val vpn = input.optJSONObject("vpn") - val builder = Builder() - .setSession("VPN App") - .setMtu(vpn?.optInt("mtu", 1280) ?: 1280) - .addAddress( - vpn?.optString("tun_address", "10.0.0.1") ?: "10.0.0.1", - vpn?.optInt("tun_prefix", 24) ?: 24, - ) - .addRoute("0.0.0.0", 0) - .addRoute("::", 0) - tun = builder.establish() - if (tun == null) { - store.write("error", "Android did not establish the VPN interface", request = input) - stopSelf() - return - } adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter() - val engineFd = if (engineName == "freeturn") { - ParcelFileDescriptor.dup(tun!!.fileDescriptor).detachFd() - } else { - tun!!.fd - } - val result = adapter!!.start(this, requestJson, engineFd) + val result = adapter!!.start(this, requestJson, null) if (!result.supported) { - tun?.close() - tun = null store.write("unsupported", result.message, request = input) stopSelf() return } - store.write("running", request = input) + val payload = org.json.JSONObject() + if (result.proxyAddresses.isNotEmpty()) { + payload.put("addresses", org.json.JSONArray(result.proxyAddresses)) + } + store.write("running", payload = payload, request = input) } private fun stopVpn() { - val wasActive = adapter != null || tun != null + val wasActive = adapter != null adapter?.stop() adapter = null - tun?.close() - tun = null if (wasActive) store.write("stopped") stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() diff --git a/native/android/libbox.version b/native/android/libbox.version new file mode 100644 index 0000000..d0147a5 --- /dev/null +++ b/native/android/libbox.version @@ -0,0 +1 @@ +v1.14.1-lx.8 diff --git a/native/android/libs/README.md b/native/android/libs/README.md index 79c652b..747b9e3 100644 --- a/native/android/libs/README.md +++ b/native/android/libs/README.md @@ -5,11 +5,11 @@ artifacts here before making a release APK: - `freeturn.aar` — built from the FreeTurn commit recorded in `THIRD_PARTY_NOTICES.md`. -- `libbox.aar` — the exact SignBox artifact with the API used by - `EngineAdapter.kt`; a generic sing-box AAR is not assumed to be compatible. +- `libbox.aar` — fetched from `Leadaxe/sing-box-lx` at the version pinned in + `native/android/libbox.version` and verified against release `SHA256SUMS`. `scripts/build-android.sh` extracts `classes.jar` and `jni/*` from each AAR into the Flet Android extension because Gradle does not allow a direct local AAR -dependency while building the extension AAR. It then runs the normal Flet APK -build. Use `REQUIRE_NATIVE_ARTIFACTS=1` for a release build so no engine is -silently omitted. +dependency while building the extension AAR. The current APK packages libbox only: +FreeTurn and libbox contain incompatible global gomobile runtime classes. Use +`REQUIRE_NATIVE_ARTIFACTS=1` for a release build. diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 795024b..4302f72 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -5,6 +5,9 @@ cd "$(dirname "$0")/.." command -v uv >/dev/null || { echo "uv is required" >&2; exit 1; } BRIDGE_ANDROID_DIR="native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android" +# setuptools otherwise reuses a half-populated wheel tree after a failed/aborted +# native build and can fail with a misleading dist-info "File exists" error. +rm -rf native/android/flet_vpn_bridge/build native/android/flet_vpn_bridge/src/vpn_native_bridge.egg-info mkdir -p "$BRIDGE_ANDROID_DIR/libs" # Remove artifacts from older builds that used the unsupported direct-AAR # dependency path. The reviewed source artifacts remain under native/android. @@ -36,16 +39,23 @@ prepare_engine_artifact() { fi } -for artifact in freeturn.aar libbox.aar; do - prepare_engine_artifact "$artifact" -done +# Two independently generated gomobile AARs both own incompatible global +# go.Seq/go.Universe classes. D8 cannot package them in one APK and choosing one +# runtime for both can crash JNI. The current MVP is deliberately libbox-first; +# FreeTurn remains a separately pinned artifact until its binding is namespaced. +if [[ -f native/android/libs/libbox.aar ]]; then + prepare_engine_artifact libbox.aar + rm -f "$BRIDGE_ANDROID_DIR/libs/freeturn-classes.jar" + echo "Note: FreeTurn Android binding excluded: gomobile runtime namespace conflicts with libbox.aar." +elif [[ -f native/android/libs/freeturn.aar ]]; then + prepare_engine_artifact freeturn.aar +fi -if [[ ! -f native/android/libs/freeturn.aar || ! -f native/android/libs/libbox.aar ]]; then +if [[ ! -f native/android/libs/libbox.aar ]]; then cat >&2 <<'EOF' -Warning: one or more native engine artifacts are missing. -The APK will build, but the affected runtime reports UNSUPPORTED and cannot connect. -For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing reviewed, pinned files at: - native/android/libs/freeturn.aar +Warning: the primary Android native engine artifact is missing. +The APK will build, but SignBox reports UNSUPPORTED and cannot connect. +For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing the reviewed, pinned file at: native/android/libs/libbox.aar EOF if [[ "${REQUIRE_NATIVE_ARTIFACTS:-0}" == "1" ]]; then diff --git a/scripts/fetch-engine-artifacts.sh b/scripts/fetch-engine-artifacts.sh index a8aadb6..03fa92e 100755 --- a/scripts/fetch-engine-artifacts.sh +++ b/scripts/fetch-engine-artifacts.sh @@ -4,15 +4,28 @@ set -euo pipefail cd "$(dirname "$0")/.." mkdir -p native/android/libs -echo "This script does not download unpinned native binaries." +LIBBOX_REPO="Leadaxe/sing-box-lx" +LIBBOX_VERSION="$(tr -d '[:space:]' < native/android/libbox.version)" +LIBBOX_FILE="libbox-${LIBBOX_VERSION#v}.aar" +LIBBOX_BASE="https://github.com/$LIBBOX_REPO/releases/download/$LIBBOX_VERSION" +download_dir="$(mktemp -d)" +trap 'rm -rf "$download_dir"' EXIT + +echo "Fetching pinned $LIBBOX_REPO $LIBBOX_VERSION" +curl -fsSL --retry 3 -o "$download_dir/$LIBBOX_FILE" "$LIBBOX_BASE/$LIBBOX_FILE" +curl -fsSL --retry 3 -o "$download_dir/SHA256SUMS" "$LIBBOX_BASE/SHA256SUMS" +( + cd "$download_dir" + grep -E " ?$LIBBOX_FILE\$" SHA256SUMS > expected.sum + sha256sum -c expected.sum +) +mv "$download_dir/$LIBBOX_FILE" native/android/libs/libbox.aar echo echo "FreeTurn upstream (${FREETURN_REF:-master}):" echo " git clone --depth 1 --branch ${FREETURN_REF:-master} https://github.com/samosvalishe/free-turn-proxy.git /tmp/free-turn-proxy" echo " cd /tmp/free-turn-proxy && task build:android" echo " cp dist/freeturn.aar $PWD/native/android/libs/freeturn.aar" -echo -echo "SignBox/sing-box artifact: provide the exact libbox.aar/private SignBox AAR and record its source/commit in THIRD_PARTY_NOTICES.md." -echo "The upstream build shape is: go install github.com/sagernet/gomobile/cmd/gomobile@; make lib_android" +echo "FreeTurn is not packaged with libbox yet: both AARs contain incompatible global gomobile runtimes." echo for artifact in native/android/libs/freeturn.aar native/android/libs/libbox.aar; do if [[ -f "$artifact" ]]; then diff --git a/tests/test_native_backend.py b/tests/test_native_backend.py new file mode 100644 index 0000000..81680d5 --- /dev/null +++ b/tests/test_native_backend.py @@ -0,0 +1,66 @@ +import asyncio + +import pytest + +from app.core.errors import UnsupportedEngineError +from app.core.native import NativeEngineBackend +from app.models import EngineKind, ProxyAccess, ProxySettings, RunMode, RuntimeRequest + + +class Bridge: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def get_runtime_state(self): + return {"state": "stopped"} + + async def start_vpn(self, payload): + self.calls.append("start_vpn") + return {"state": "starting"} + + async def start_proxy(self, payload): + self.calls.append("start_proxy") + return {"state": "starting"} + + async def start_hotspot_proxy(self, payload): + self.calls.append("start_hotspot_proxy") + return {"state": "starting"} + + +def request(mode: RunMode, access: ProxyAccess = ProxyAccess.LOCAL) -> RuntimeRequest: + return RuntimeRequest( + engine=EngineKind.SIGNBOX, + mode=mode, + profile_id="profile", + proxy=ProxySettings(access=access), + ) + + +def test_android_signbox_capabilities_match_pinned_libbox() -> None: + backend = NativeEngineBackend(EngineKind.SIGNBOX, Bridge()) + assert backend.capabilities.supports_vpn + assert backend.capabilities.supports_proxy + assert backend.capabilities.supports_both + assert backend.capabilities.supports_socks + assert backend.capabilities.supports_http + + +def test_local_both_starts_one_vpn_owned_core() -> None: + async def run() -> None: + bridge = Bridge() + backend = NativeEngineBackend(EngineKind.SIGNBOX, bridge) + await backend.start(request(RunMode.BOTH)) + assert bridge.calls == ["start_vpn"] + + asyncio.run(run()) + + +def test_hotspot_both_fails_before_starting_two_cores() -> None: + async def run() -> None: + bridge = Bridge() + backend = NativeEngineBackend(EngineKind.SIGNBOX, bridge) + with pytest.raises(UnsupportedEngineError): + await backend.start(request(RunMode.BOTH, ProxyAccess.HOTSPOT)) + assert bridge.calls == [] + + asyncio.run(run())