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
```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

View file

@ -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.

View file

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

View file

@ -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 []),

View file

@ -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.

View file

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

View file

@ -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.

View file

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

View file

@ -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.

View file

@ -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<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 {

View file

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

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

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
`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.

View file

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

View file

@ -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@<pinned>; 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

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