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