Integrate sing-box-lx libbox on Android
This commit is contained in:
parent
449ce76751
commit
d021fa9981
19 changed files with 561 additions and 137 deletions
19
README.md
19
README.md
|
|
@ -55,25 +55,28 @@ User-Agent strings are not authentication.
|
|||
## Build scripts
|
||||
|
||||
```bash
|
||||
./scripts/fetch-engine-artifacts.sh # optional, requires upstream build tools
|
||||
./scripts/fetch-engine-artifacts.sh # fetches pinned libbox + verifies SHA256SUMS
|
||||
./scripts/build-android.sh # flet build apk (requires Android SDK/Flutter)
|
||||
./scripts/build-desktop.sh linux # flet build linux
|
||||
./scripts/run-backend.sh # local backend
|
||||
```
|
||||
|
||||
For Android, `./scripts/build-android.sh` builds an arm64 APK with any reviewed artifacts
|
||||
present locally; missing engines report `UNSUPPORTED` at runtime. For a releasable build,
|
||||
place the exact `freeturn.aar` and compatible `libbox.aar` under
|
||||
`native/android/libs/` (the directory is ignored to avoid silently shipping unreviewed
|
||||
native code) and set `REQUIRE_NATIVE_ARTIFACTS=1`.
|
||||
For Android, the fetch script downloads `Leadaxe/sing-box-lx` at the pinned version.
|
||||
`REQUIRE_NATIVE_ARTIFACTS=1 ./scripts/build-android.sh` then builds an arm64 APK.
|
||||
Artifacts stay ignored; versions and hashes live in `native/android/libbox.version` and
|
||||
`THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
The current Android APK is libbox-first. FreeTurn is not packaged alongside it because
|
||||
the independently generated gomobile AARs contain incompatible global runtime classes;
|
||||
see `docs/implementation-status.md`.
|
||||
|
||||
## Security posture
|
||||
|
||||
The default proxy bind is localhost. Wi-Fi sharing is explicit, binds to the discovered
|
||||
LocalOnlyHotspot address, and requires authentication by default. Management/debug APIs
|
||||
are disabled by default. Secrets are redacted from logs. Application routing models
|
||||
distinguish `PROXY`, `DIRECT`, and `VOID`; native UID matching and fail-closed `VOID`
|
||||
enforcement are not yet shipped and are never presented as active security.
|
||||
distinguish `PROXY`, `DIRECT`, and `VOID`; DIRECT excludes the package from VpnService,
|
||||
while VOID fails startup until its native fail-closed classifier is shipped.
|
||||
|
||||
## License and upstreams
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
1
native/android/libbox.version
Normal file
1
native/android/libbox.version
Normal file
|
|
@ -0,0 +1 @@
|
|||
v1.14.1-lx.8
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
tests/test_native_backend.py
Normal file
66
tests/test_native_backend.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue