Integrate sing-box-lx libbox on Android

This commit is contained in:
SNW 2026-09-23 09:53:07 +00:00
parent 449ce76751
commit d021fa9981
19 changed files with 561 additions and 137 deletions

View file

@ -55,25 +55,28 @@ User-Agent strings are not authentication.
## Build scripts ## Build scripts
```bash ```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-android.sh # flet build apk (requires Android SDK/Flutter)
./scripts/build-desktop.sh linux # flet build linux ./scripts/build-desktop.sh linux # flet build linux
./scripts/run-backend.sh # local backend ./scripts/run-backend.sh # local backend
``` ```
For Android, `./scripts/build-android.sh` builds an arm64 APK with any reviewed artifacts For Android, the fetch script downloads `Leadaxe/sing-box-lx` at the pinned version.
present locally; missing engines report `UNSUPPORTED` at runtime. For a releasable build, `REQUIRE_NATIVE_ARTIFACTS=1 ./scripts/build-android.sh` then builds an arm64 APK.
place the exact `freeturn.aar` and compatible `libbox.aar` under Artifacts stay ignored; versions and hashes live in `native/android/libbox.version` and
`native/android/libs/` (the directory is ignored to avoid silently shipping unreviewed `THIRD_PARTY_NOTICES.md`.
native code) and set `REQUIRE_NATIVE_ARTIFACTS=1`.
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 ## Security posture
The default proxy bind is localhost. Wi-Fi sharing is explicit, binds to the discovered 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 LocalOnlyHotspot address, and requires authentication by default. Management/debug APIs
are disabled by default. Secrets are redacted from logs. Application routing models are disabled by default. Secrets are redacted from logs. Application routing models
distinguish `PROXY`, `DIRECT`, and `VOID`; native UID matching and fail-closed `VOID` distinguish `PROXY`, `DIRECT`, and `VOID`; DIRECT excludes the package from VpnService,
enforcement are not yet shipped and are never presented as active security. while VOID fails startup until its native fail-closed classifier is shipped.
## License and upstreams ## License and upstreams

View file

@ -1,19 +1,18 @@
# Third-party notices # Third-party notices
This file is intentionally explicit about components that may be bundled. Native 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 artifacts are not committed to this repository; their version and SHA-256 are pinned
recorded here before a release artifact is built. here and in the fetch script.
| Component | Use | Version/commit | License | Source | | Component | Use | Version/commit | License | Source |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Flet | Python UI/runtime packaging | pinned by `uv.lock` | Apache-2.0 | https://github.com/flet-dev/flet | | 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 | | 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 | | 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 | | 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 The Android reference applications are not copied. Distribution of an APK containing
apply to an independent implementation that only uses documented APIs, but distributing `libbox.so` must comply with sing-box-lx's GPL and additional LICENSE term; the MIT
the FreeTurn GPL mobile library still requires complying with its license and providing license applies only to original code and does not relicense the combined binary.
corresponding notices/source obligations as applicable.

View file

@ -35,16 +35,18 @@ class NativeEngineBackend(EngineBackend):
def _capabilities(kind: EngineKind) -> CapabilitySet: def _capabilities(kind: EngineKind) -> CapabilitySet:
if kind is EngineKind.FREETURN: if kind is EngineKind.FREETURN:
return CapabilitySet( return CapabilitySet(
supports_proxy=True,
supports_socks=False,
supports_udp=True,
supports_mobile=True, supports_mobile=True,
supports_stats=True,
) )
return CapabilitySet( return CapabilitySet(
# The exact SignBox AAR/API is a release input. Keeping these false supports_vpn=True,
# prevents the UI from advertising a native path that is not bundled. supports_proxy=True,
supports_socks=True,
supports_http=True,
supports_udp=True,
supports_mobile=True, supports_mobile=True,
supports_stats=False,
supports_hot_reload=False,
supports_both=True,
) )
async def start(self, request: RuntimeRequest) -> RuntimeSnapshot: async def start(self, request: RuntimeRequest) -> RuntimeSnapshot:
@ -69,6 +71,11 @@ class NativeEngineBackend(EngineBackend):
self._state = self._snapshot_from_result(existing, request, existing_state) self._state = self._snapshot_from_result(existing, request, existing_state)
return self._state return self._state
self._request = request 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: if request.mode is RunMode.PROXY:
result = ( result = (
await self.bridge.start_proxy(payload) await self.bridge.start_proxy(payload)
@ -77,14 +84,6 @@ class NativeEngineBackend(EngineBackend):
) )
else: else:
result = await self.bridge.start_vpn(payload) 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) self._state = self._snapshot_from_result(result, request, EngineState.STARTING)
return self._raise_if_terminal_error(self._state) return self._raise_if_terminal_error(self._state)

View file

@ -162,7 +162,7 @@ def build_vpn_screen(
), ),
*([ *([
ft.Text( ft.Text(
"SignBox native artifact/API ещё не подключён; подключение недоступно.", "Нативный SignBox runtime недоступен в этой сборке.",
color=ft.Colors.ERROR, color=ft.Colors.ERROR,
) )
] if not engine_ready else []), ] if not engine_ready else []),

View file

@ -38,8 +38,8 @@ FreeTurn desktop is explicitly unsupported.
Android state is authoritative in native `SharedPreferences`. Reopening the Flet UI must 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. 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 The native bridge has this contract. `sing-box-lx` is pinned and runs in-process; the
be pinned before the adapter can report `RUNNING`. service owns `CommandServer`, while `CommandClient` is its local management channel.
## Engine abstraction ## Engine abstraction
@ -50,9 +50,9 @@ are rendered as `Недоступно`, not as connected.
Internals remain different: Internals remain different:
- Once the exact native artifact is supplied, SignBox can expose VLESS and AmneziaWG - SignBox exposes VLESS and AmneziaWG packet/TUN plus sing-box SOCKS/HTTP inbounds.
packet/TUN plus local SOCKS/HTTP. The current adapter deliberately reports `PlatformInterface.openTun` is the only creator of the Android TUN; Python and the
`UNSUPPORTED` until that API is pinned and implemented. Activity never own its fd.
- FreeTurn uses its own mobile binding and event/CAPTCHA lifecycle. Its `Start` API is - 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 not a generic SOCKS5/HTTP listener; standard sharing remains unsupported until a
separately implemented listener reports actual bound addresses. separately implemented listener reports actual bound addresses.
@ -62,8 +62,10 @@ Internals remain different:
## Modes and sharing ## Modes and sharing
Desktop is proxy-only in the first release. Android supports `TUN`, `Proxy`, and Desktop is proxy-only in the first release. Android supports VPN, proxy-only, and local
`Both` only when the selected native artifact advertises the matching capability. `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 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 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 ## Deliberate non-goals
- iOS, Personal Hotspot automation and iOS per-app routing. - iOS, Personal Hotspot automation and iOS per-app routing.
- Android application routing (`DIRECT`/`VOID`) until native UID ownership and the - `DIRECT` uses Android package exclusion. `VOID` remains unavailable until native UID
fail-closed packet drop path are implemented and tested on real devices. ownership and the fail-closed packet drop path are implemented and device-tested.
- bundled native cores without pinned source, license, ABI and API evidence. - bundled native cores without pinned source, license, ABI and API evidence.
- management/debug listeners exposed by SignBox. - management/debug listeners exposed by SignBox.

View file

@ -30,8 +30,8 @@ the admin token in a public URL.
## Android ## Android
The Flet shell can be built without native core artifacts; runtime connections then The Flet shell can be built without a native core artifact; runtime connections then
return `UNSUPPORTED` honestly. A release build can enforce both artifacts: return `UNSUPPORTED` honestly. A release build pins and enforces libbox:
```bash ```bash
./scripts/fetch-engine-artifacts.sh ./scripts/fetch-engine-artifacts.sh
@ -54,11 +54,14 @@ task build:android
cp dist/freeturn.aar /path/to/repo/native/android/libs/ cp dist/freeturn.aar /path/to/repo/native/android/libs/
``` ```
For SignBox, use the exact artifact that exposes the API expected by `scripts/fetch-engine-artifacts.sh` downloads `Leadaxe/sing-box-lx` at the version in
`native/android/flet_vpn_bridge/src/flutter/vpn_native_bridge/android/.../EngineAdapter.kt`. A generic sing-box `native/android/libbox.version`, verifies release `SHA256SUMS`, and installs it locally.
`libbox.aar` is not silently assumed to be the user's private SignBox fork. Record its To upgrade, change that version, run the fetch script, inspect the AAR API with `javap`,
commit, build tags, ABIs, license and SHA-256 in `THIRD_PARTY_NOTICES.md`, then replace update `THIRD_PARTY_NOTICES.md`, rebuild, and execute the Android manual test matrix.
the adapter with calls from that artifact and add a native integration test.
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 ## Desktop

View file

@ -4,25 +4,29 @@
- Flet web shell starts; Linux desktop bundle builds. - Flet web shell starts; Linux desktop bundle builds.
- ARM64 Android APK builds with current Flet/Gradle toolchain. - ARM64 Android APK builds with current Flet/Gradle toolchain.
- FreeTurn `freeturn.aar` from the pinned upstream commit is embedded as - `sing-box-lx v1.14.1-lx.8` is fetched with upstream checksum verification and
`classes.jar + jniLibs`; Kotlin reflection calls the documented mobile API, packaged as `libbox.so`; the ARM64 APK contains the real native engine.
installs `EventSink`/`Protector`, and keeps its state in the foreground service. - 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, - 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. User-Agent binding and subscription response pass tests and a local HTTP smoke test.
- Python lifecycle, conflict rules, serialization, billing date, proxy settings, - Python lifecycle, conflict rules, serialization, billing date, proxy settings,
enrollment URL and port-selection tests pass. 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 - A physical-device run with production VLESS and AmneziaWG profiles is still required;
native adapter and Android VPN modes report `UNSUPPORTED`; no generic sing-box a successful compile does not prove remote connectivity.
artifact is guessed. - Proxy-only LocalOnlyHotspot starts listeners on the discovered address. `Both +
- Android SOCKS5/HTTP listener, Wi-Fi QR sharing and `Both`: the current adapter hotspot` is explicitly rejected until one-core startup is coordinated across hotspot
does not report actual bound listeners, so hotspot sharing fails closed instead discovery and `VpnService`.
of showing ports that do not exist. - `DIRECT` is applied with package exclusion. `VOID` rejects startup as unsupported;
- `DIRECT`/`VOID` application routing: models and UI boundary exist, but UID the fail-closed UID-aware packet classifier is not implemented.
ownership, `addDisallowedApplication` for DIRECT and native fail-closed packet - FreeTurn's reviewed AAR cannot coexist with libbox yet because both ship incompatible
dropping for VOID are not implemented. 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 - Backend sync is a reusable client contract with injected secret storage; wiring
it to Android Keystore, an enrollment screen and automatic profile refresh remains. 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 ## Device acceptance still required
Use `docs/android-manual-tests.md` on a physical Android API 26+ device after the Use `docs/android-manual-tests.md` on a physical Android API 26+ device. In particular test VPN
SignBox artifact and listener implementation are added. In particular test VPN
consent, screen lock/UI death, service conflict, backend crash, hotspot permissions, consent, screen lock/UI death, service conflict, backend crash, hotspot permissions,
actual interface address, laptop SOCKS/HTTP traffic and the aggressive VPN-detection actual interface address, laptop SOCKS/HTTP traffic and the aggressive VPN-detection
VOID case. VOID case.

View file

@ -1,6 +1,6 @@
# Upstream/API research snapshot # Upstream/API research snapshot
Checked 2026-09-22. Checked 2026-09-23.
## Flet ## 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. `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. 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 + The selected fork is `Leadaxe/sing-box-lx`, pinned to `v1.14.1-lx.8`. Its release AAR
AmneziaWG 3.1 claim were not found. Upstream sing-box exposes `libbox.aar` through is verified against upstream `SHA256SUMS`; it exposes `CommandServer`, `CommandClient`,
gomobile, but its package/API and build tags must be checked against the actual SignBox `PlatformInterface`, VLESS and AmneziaWG 3.x features. LxBox confirms Android lifecycle
artifact before compiling calls. The repo therefore has a real adapter boundary and a uses `CommandServer` and manages the live core through an in-process `CommandClient`,
hard `UNSUPPORTED` result rather than a fabricated success. 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 ## Removed scope

View file

@ -13,28 +13,27 @@ Python/Flet -> FletService -> MethodChannel -> Kotlin bridge
-> FreeTurn/libbox adapters -> FreeTurn/libbox adapters
``` ```
`VpnRuntimeService` calls `VpnService.prepare()` through the bridge, starts foreground `VpnRuntimeService` calls `VpnService.prepare()` through the bridge and starts foreground
immediately, configures a predictable `10.0.0.1/24` TUN, and persists state in immediately. The pinned libbox core asks `PlatformInterface.openTun()` to establish the
`SharedPreferences`. The current adapter refuses to report success unless the expected TUN from its effective config; this prevents a fake Connected state with an unowned
native engine classes are bundled. This avoids a fake VPN that installs a black-hole black-hole interface. Runtime state is persisted in `SharedPreferences`.
interface.
## Native artifacts ## Native artifacts
Put reviewable, pinned artifacts in `libs/` locally: Put reviewable, pinned artifacts in `libs/` locally:
- `freeturn.aar` from the FreeTurn `mobile` package. - `libbox.aar` from `Leadaxe/sing-box-lx`, version pinned in `libbox.version`.
- `libbox.aar` or the exact private SignBox AAR with VLESS + AmneziaWG 3.1 support. - `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 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` `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 downloads the pinned libbox release and verifies its upstream `SHA256SUMS`.
download an unpinned binary.
`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 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 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`. make a smoke APK pretend to work: the corresponding adapter returns `UNSUPPORTED`.
The first native milestone still needs device testing for VPN consent, foreground The native milestone still needs physical-device testing for VPN consent, foreground
service timing, packet forwarding, hotspot interface discovery, and engine ABI/API. service timing, packet forwarding, hotspot interface discovery, VLESS and AmneziaWG.

View file

@ -1,6 +1,23 @@
package su.snw.vpn_native_bridge package su.snw.vpn_native_bridge
import android.content.Context 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 org.json.JSONObject
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Proxy import java.lang.reflect.Proxy
@ -16,17 +33,117 @@ internal interface EngineAdapter {
fun stop(): AdapterResult fun stop(): AdapterResult
} }
/** /** Owns one in-process sing-box-lx service. No management/debug TCP port is opened. */
* The libbox API is intentionally isolated here. The exact AAR/signbox package is a internal class SignBoxAdapter : EngineAdapter, CommandServerHandler {
* release input, not an invented compile-time API. Until it is pinned, refusing to private var server: CommandServer? = null
* start is safer than showing a connected TUN with no packet engine behind it. private var client: CommandClient? = null
*/ private var platform: SignBoxPlatform? = null
internal class SignBoxAdapter : EngineAdapter {
override fun start(context: Context, requestJson: String, tunFd: Int?): AdapterResult { 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<String>()
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<String>()
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<String>) : 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 { internal class FreeTurnAdapter : EngineAdapter {

View file

@ -89,7 +89,10 @@ internal class RuntimeStore(context: Context) {
val message = components.firstOrNull { val message = components.firstOrNull {
it.optString("state") in setOf("error", "unsupported") it.optString("state") in setOf("error", "unsupported")
}?.optString("message", null) }?.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() return JSONObject()
.put("state", state) .put("state", state)
.put("message", message) .put("message", message)

View file

@ -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<String>,
)
/** 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<String> {
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<String>()
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 }
}

View file

@ -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=<redacted>")
}

View file

@ -7,12 +7,10 @@ import android.content.Intent
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.ParcelFileDescriptor
import java.util.concurrent.Executors import java.util.concurrent.Executors
class VpnRuntimeService : VpnService() { class VpnRuntimeService : VpnService() {
private lateinit var store: RuntimeStore private lateinit var store: RuntimeStore
private var tun: ParcelFileDescriptor? = null
private var adapter: EngineAdapter? = null private var adapter: EngineAdapter? = null
private var intentRequest: String = "{}" private var intentRequest: String = "{}"
private val worker = Executors.newSingleThreadExecutor() private val worker = Executors.newSingleThreadExecutor()
@ -45,45 +43,24 @@ class VpnRuntimeService : VpnService() {
return return
} }
store.write("starting", request = input) 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() adapter = if (engineName == "freeturn") FreeTurnAdapter() else SignBoxAdapter()
val engineFd = if (engineName == "freeturn") { val result = adapter!!.start(this, requestJson, null)
ParcelFileDescriptor.dup(tun!!.fileDescriptor).detachFd()
} else {
tun!!.fd
}
val result = adapter!!.start(this, requestJson, engineFd)
if (!result.supported) { if (!result.supported) {
tun?.close()
tun = null
store.write("unsupported", result.message, request = input) store.write("unsupported", result.message, request = input)
stopSelf() stopSelf()
return 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() { private fun stopVpn() {
val wasActive = adapter != null || tun != null val wasActive = adapter != null
adapter?.stop() adapter?.stop()
adapter = null adapter = null
tun?.close()
tun = null
if (wasActive) store.write("stopped") if (wasActive) store.write("stopped")
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()

View file

@ -0,0 +1 @@
v1.14.1-lx.8

View file

@ -5,11 +5,11 @@ artifacts here before making a release APK:
- `freeturn.aar` — built from the FreeTurn commit recorded in - `freeturn.aar` — built from the FreeTurn commit recorded in
`THIRD_PARTY_NOTICES.md`. `THIRD_PARTY_NOTICES.md`.
- `libbox.aar` — the exact SignBox artifact with the API used by - `libbox.aar` — fetched from `Leadaxe/sing-box-lx` at the version pinned in
`EngineAdapter.kt`; a generic sing-box AAR is not assumed to be compatible. `native/android/libbox.version` and verified against release `SHA256SUMS`.
`scripts/build-android.sh` extracts `classes.jar` and `jni/*` from each AAR into `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 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 dependency while building the extension AAR. The current APK packages libbox only:
build. Use `REQUIRE_NATIVE_ARTIFACTS=1` for a release build so no engine is FreeTurn and libbox contain incompatible global gomobile runtime classes. Use
silently omitted. `REQUIRE_NATIVE_ARTIFACTS=1` for a release build.

View file

@ -5,6 +5,9 @@ cd "$(dirname "$0")/.."
command -v uv >/dev/null || { echo "uv is required" >&2; exit 1; } 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" 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" mkdir -p "$BRIDGE_ANDROID_DIR/libs"
# Remove artifacts from older builds that used the unsupported direct-AAR # Remove artifacts from older builds that used the unsupported direct-AAR
# dependency path. The reviewed source artifacts remain under native/android. # dependency path. The reviewed source artifacts remain under native/android.
@ -36,16 +39,23 @@ prepare_engine_artifact() {
fi fi
} }
for artifact in freeturn.aar libbox.aar; do # Two independently generated gomobile AARs both own incompatible global
prepare_engine_artifact "$artifact" # go.Seq/go.Universe classes. D8 cannot package them in one APK and choosing one
done # 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' cat >&2 <<'EOF'
Warning: one or more native engine artifacts are missing. Warning: the primary Android native engine artifact is missing.
The APK will build, but the affected runtime reports UNSUPPORTED and cannot connect. The APK will build, but SignBox reports UNSUPPORTED and cannot connect.
For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing reviewed, pinned files at: For a release build set REQUIRE_NATIVE_ARTIFACTS=1 after placing the reviewed, pinned file at:
native/android/libs/freeturn.aar
native/android/libs/libbox.aar native/android/libs/libbox.aar
EOF EOF
if [[ "${REQUIRE_NATIVE_ARTIFACTS:-0}" == "1" ]]; then if [[ "${REQUIRE_NATIVE_ARTIFACTS:-0}" == "1" ]]; then

View file

@ -4,15 +4,28 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
mkdir -p native/android/libs 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
echo "FreeTurn upstream (${FREETURN_REF:-master}):" 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 " 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 " cd /tmp/free-turn-proxy && task build:android"
echo " cp dist/freeturn.aar $PWD/native/android/libs/freeturn.aar" echo " cp dist/freeturn.aar $PWD/native/android/libs/freeturn.aar"
echo echo "FreeTurn is not packaged with libbox yet: both AARs contain incompatible global gomobile runtimes."
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@<pinned>; make lib_android"
echo echo
for artifact in native/android/libs/freeturn.aar native/android/libs/libbox.aar; do for artifact in native/android/libs/freeturn.aar native/android/libs/libbox.aar; do
if [[ -f "$artifact" ]]; then if [[ -f "$artifact" ]]; then

View file

@ -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())