diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa9fc78 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# Docker build context is the repository root (bench/Dockerfile): send only what it uses. +* +!bench/ +!server/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c530d09 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,253 @@ +# +# build.yml — VTesse CI: tests, static musl builds for camera/router targets, +# the Docker test bench, releases. +# +# The VTun server (vtund) is built with our patches (server/build-vtund.sh, +# server/patches) for PCs/servers only, in the x86_64 job, and released as +# vtund--x86_64. Cameras and routers build it in the OpenIPC +# environment when needed. +# +# Static musl binaries (a static glibc binary cannot resolve host names without +# the NSS libraries of the same glibc on the device). 32-bit ARM uses the OpenIPC +# firmware toolchain, MIPS (both byte orders) the OpenWrt toolchains (musl, +# soft-float: no FPU needed on routers), the rest Bootlin musl toolchains +# (ci/build-musl.sh). +# +# Release: one release only, "latest", replaced by every push to main. Its title +# carries the version from include/vtesse/version.h (VTESSE_VERSION "MAJOR.MM") +# and its notes are one short line; the change history lives in CHANGELOG.md. +# No versioned releases are kept. +# + +name: VTesse CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +# One run per branch and event: a newer push cancels the older run, so +# "latest" always carries the build of the newest commit. +concurrency: + group: build-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ------------------------------------------------------------------ tests + tests: + name: Unit tests (native, sanitizers) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Build and test + run: | + make -j"$(nproc)" + make test + + - name: Test under ASan/UBSan + run: | + make -j"$(nproc)" BUILD=build-asan \ + CFLAGS="-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer" \ + LDFLAGS="-fsanitize=address,undefined" test + + - name: TAP-only build (LWIP=0) + run: make -j"$(nproc)" BUILD=build-tap LWIP=0 all + + - name: Minimal lwIP build (SOCKS=0 UDP_RELAY=0 TAP=0) and its tests + run: make -j"$(nproc)" BUILD=build-min SOCKS=0 UDP_RELAY=0 TAP=0 test + + # ------------------------------------------------------------------ cross + build: + name: Build ${{ matrix.target }} (musl static) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [arm32, mipsel, arm64, mips, x86_64] + env: + TOOLCHAIN_CACHE: ${{ github.workspace }}/.toolchains + steps: + - uses: actions/checkout@v7 + + - name: Install qemu-user and the vtund build tools + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq qemu-user + # vtund (x86_64 job only) + if [ "${{ matrix.target }}" = x86_64 ]; then sudo apt-get install -y -qq bison flex autotools-dev; fi + + - name: Cache toolchain + uses: actions/cache@v6 + with: + path: .toolchains + key: toolchain-${{ matrix.target }}-${{ hashFiles('ci/build-musl.sh') }} + + - name: Cache vtun sources + if: matrix.target == 'x86_64' + uses: actions/cache@v6 + with: + path: ~/.cache/vtesse-vtun + key: vtun-${{ hashFiles('server/build-vtund.sh') }} + + - name: Build and run the unit tests under qemu + run: QEMU=1 ci/build-musl.sh ${{ matrix.target }} dist + + - name: Upload binaries + uses: actions/upload-artifact@v7 + with: + name: vtesse-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + # ------------------------------------------------------------------ bench + bench: + name: Test bench (VTun 3.0.2 + our patches, in Docker) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Build + run: make -j"$(nproc)" + + - name: Start the bench + run: | + bench/bench.sh build + bench/bench.sh up + sleep 5 + + - name: Server self-test + run: bench/bench.sh selftest + + - name: Stage 1 acceptance (lwIP, DHCP) + run: bench/bench.sh cli-test + + - name: Stage 2 acceptance (SOCKS5, port forwards) + run: bench/bench.sh proxy-test + + - name: TAP backend acceptance + run: bench/bench.sh tap-test + + - name: type tun acceptance (manual IPv4, lwIP and TAP) + run: bench/bench.sh tun-test + + - name: UDP relay acceptance (RTP from cameras) + run: bench/bench.sh udp-test + + - name: Failure scenarios (link loss, server restart, no option 121, lease renewal) + run: bench/bench.sh fault-test + + - name: Server log + if: always() + run: docker logs vtesse-bench-server 2>&1 | tail -100 || true + + # ---------------------------------------------------------------- android + android: + name: Android APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Cache Gradle + uses: actions/cache@v6 + with: + path: android/.gradle-cache + key: gradle-${{ hashFiles('android/**/*.kts', 'android/gradle.properties') }} + + - name: Build APKs (Docker, ci/Dockerfile.android) + env: + KEYSTORE_BASE64: ${{ secrets.VTESSE_KEYSTORE_BASE64 }} + VTESSE_KEYSTORE_PASSWORD: ${{ secrets.VTESSE_KEYSTORE_PASSWORD }} + VTESSE_KEY_ALIAS: ${{ secrets.VTESSE_KEY_ALIAS }} + VTESSE_KEY_PASSWORD: ${{ secrets.VTESSE_KEY_PASSWORD }} + run: | + # release signing key from the repository secrets (absent in forks: unsigned) + if [ -n "$KEYSTORE_BASE64" ]; then + mkdir -p "$RUNNER_TEMP/keys" + echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keys/vtesse-release.jks" + export VTESSE_KEYSTORE="$RUNNER_TEMP/keys/vtesse-release.jks" + fi + android/build.sh assembleDebug assembleRelease + rm -rf "$RUNNER_TEMP/keys" + + - name: Upload APKs + uses: actions/upload-artifact@v7 + with: + name: vtesse-android + path: android/app/build/outputs/apk/*/*.apk + if-no-files-found: error + + # ---------------------------------------------------------------- release + release: + name: Release + needs: [tests, build, bench, android] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + path: artifacts + merge-multiple: true + + - name: Collect assets + id: vars + env: + GH_TOKEN: ${{ github.token }} + run: | + VER=$(sed -n 's/^#define VTESSE_VERSION "\(.*\)"/\1/p' include/vtesse/version.h) + if [ -z "$VER" ]; then echo "::error::no VTESSE_VERSION found"; exit 1; fi + echo "ver=$VER" >> "$GITHUB_OUTPUT" + # the APK goes to Telegram only when the version changes (#33) + PREV=$(gh release view latest --json name -q .name 2>/dev/null || true) + echo "Previous release: ${PREV:-none}" + [ "$PREV" = "VTesse $VER" ] || echo "new=true" >> "$GITHUB_OUTPUT" + echo "vtun=$(server/build-vtund.sh --version)" >> "$GITHUB_OUTPUT" + mkdir release + # artifacts keep their directory layout (dist/...) + find artifacts -type f \( -name 'vtesse-*' -o -name 'vtund-*' \) -exec cp {} release/ \; + [ -f "release/vtund-$(server/build-vtund.sh --version)-x86_64" ] || { echo "::error::no vtund x86_64 binary"; exit 1; } + # Android: the signed release APK only (an unsigned one is app-release-unsigned.apk) + apk=$(find artifacts -type f -name app-release.apk | head -1) + [ -n "$apk" ] || { echo "::error::no signed release APK"; exit 1; } + cp "$apk" release/vtesse-android.apk + cp scripts/vtesse-hook.sh scripts/vtesse-udhcpc.script scripts/S99vtesse scripts/vtesse.init release/ + (cd release && sha256sum * > SHA256SUMS) + ls -l release + + - name: Publish "latest" + env: + GH_TOKEN: ${{ github.token }} + VER: ${{ steps.vars.outputs.ver }} + VTUN: ${{ steps.vars.outputs.vtun }} + run: | + gh release delete latest --cleanup-tag --yes 2>/dev/null || true + gh release create latest release/* \ + --latest \ + --title "VTesse ${VER}" \ + --target "${GITHUB_SHA}" \ + --notes "VTesse ${VER} (${GITHUB_SHA::7}): static musl builds of vtesse-cli for arm32, mipsel, mips, arm64 and x86_64, the Android app (vtesse-android.apk, Android 5.0+) and the VTun ${VTUN} server with our patches for PCs (vtund-${VTUN}-x86_64). Changes: CHANGELOG.md; installation: README.md." + + # The Telegram channel gets the APK of every new version, as in OpenIPC/decoder (#33). + # Organization secrets; a failed upload is reported but does not fail the release. + - name: Telegram + if: steps.vars.outputs.new == 'true' + env: + TG_TOKEN: ${{ secrets.TELEGRAM_TOKEN_BOT_OPENIPC }} + TG_CHANNEL: ${{ secrets.TELEGRAM_CHANNEL_OPENIPC_DEV }} + VER: ${{ steps.vars.outputs.ver }} + run: | + if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHANNEL" ]; then + echo "::warning::Telegram secrets are not available, APK not posted"; exit 0 + fi + CAPTION=$(printf 'VTesse %s for Android\nCommit: %s\nTime: %s\n\n%s' "$VER" "${GITHUB_SHA::7}" \ + "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/latest") + HTTP=$(curl -sS -o tg.json -w '%{http_code}' "https://api.telegram.org/bot${TG_TOKEN}/sendDocument" \ + -F chat_id="$TG_CHANNEL" -F caption="$CAPTION" -F document=@release/vtesse-android.apk) || HTTP=000 + echo "Telegram response: $HTTP" + [ "$HTTP" = 200 ] || { echo "::warning::Telegram upload failed (HTTP $HTTP): $(cat tg.json 2>/dev/null)"; } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..70fb162 --- /dev/null +++ b/Makefile @@ -0,0 +1,109 @@ +# VTesse build. +# make CROSS_COMPILE=mipsel-linux-musl- STATIC=1 cross compile, static binary +# make LWIP=0 TAP backend only (no stack, no proxy) +# make LWIP_PROFILE=medium lwIP memory profile: large (default), +# medium (cameras, routers), small +# make SOCKS=0 UDP_RELAY=0 TAP=0 leave out optional modules (default 1) +# (use a separate BUILD=... directory per variant: objects are not rebuilt on a change) +CROSS_COMPILE ?= +ifneq ($(CROSS_COMPILE),) +CC := $(CROSS_COMPILE)gcc +AR := $(CROSS_COMPILE)ar +endif +CC ?= cc +AR ?= ar +CFLAGS ?= -Os -g +STATIC ?= 0 +# Required flags stay separate so that CFLAGS can be overridden on the command line. +LWIP_DIR := third_party/lwip +INCLUDES := -Iinclude -Iport/lwip -I$(LWIP_DIR)/src/include +BASE_CFLAGS := -std=c99 -Wall -Wextra -Wpedantic -Wshadow -D_POSIX_C_SOURCE=200809L \ + -ffunction-sections -fdata-sections $(INCLUDES) +BASE_LDFLAGS := -Wl,--gc-sections +ifeq ($(STATIC),1) +BASE_LDFLAGS += -static +endif +# vendored lwIP: unmodified upstream code, built with relaxed warnings +LWIP_CFLAGS := -std=gnu99 -Wall -Wno-unused-parameter -Wno-address \ + -ffunction-sections -fdata-sections $(INCLUDES) +LDFLAGS ?= +BUILD ?= build +LWIP ?= 1 +# lwIP memory profile (port/lwip/lwipopts.h): large (PCs, Android), medium (cameras, +# routers), small +LWIP_PROFILE ?= large +# optional modules of the lwIP build (1 = in, 0 = out): SOCKS5 server, UDP relay, +# TAP/TUN backend (kernel interface; Linux only) +SOCKS ?= 1 +UDP_RELAY ?= 1 +TAP ?= 1 + +NETCORE_SRC := netcore/tunnel.c netcore/loop.c netcore/route.c +ifeq ($(TAP),1) +NETCORE_SRC += netcore/tapdev.c +else +BASE_CFLAGS += -DVTESSE_WITH_TAP=0 +endif +VTUN_SRC := vtun/vtun_proto.c vtun/vtun_client.c +LWIP_SRC := $(addprefix $(LWIP_DIR)/src/core/, def.c inet_chksum.c init.c ip.c mem.c \ + memp.c netif.c pbuf.c stats.c sys.c tcp.c tcp_in.c tcp_out.c timeouts.c udp.c) \ + $(addprefix $(LWIP_DIR)/src/core/ipv4/, dhcp.c etharp.c icmp.c ip4.c ip4_addr.c ip4_frag.c) \ + $(LWIP_DIR)/src/netif/ethernet.c + +LWIP_PROFILE_NUM := $(if $(filter small,$(LWIP_PROFILE)),0,$(if $(filter medium,$(LWIP_PROFILE)),1,2)) +BASE_CFLAGS += -DVTESSE_LWIP_PROFILE=$(LWIP_PROFILE_NUM) +LWIP_CFLAGS += -DVTESSE_LWIP_PROFILE=$(LWIP_PROFILE_NUM) + +ifeq ($(LWIP),1) +NETCORE_SRC += netcore/stack.c netcore/proxy.c +BASE_CFLAGS += -DNC_WITH_SOCKS=$(SOCKS) +ifeq ($(UDP_RELAY),1) +NETCORE_SRC += netcore/udprelay.c +else +BASE_CFLAGS += -DVTESSE_WITH_UDP_RELAY=0 +endif +NETCORE_OBJ := $(NETCORE_SRC:%.c=$(BUILD)/%.o) $(LWIP_SRC:%.c=$(BUILD)/%.o) +else +BASE_CFLAGS += -DVTESSE_WITH_LWIP=0 +NETCORE_OBJ := $(NETCORE_SRC:%.c=$(BUILD)/%.o) +endif +VTUN_OBJ := $(VTUN_SRC:%.c=$(BUILD)/%.o) + +LIBNETCORE := $(BUILD)/libnetcore.a +LIBVTESSE := $(BUILD)/libvtesse.a +CLI := $(BUILD)/vtesse-cli +TESTS := $(BUILD)/test_proto $(BUILD)/test_client $(BUILD)/test_route + +.PHONY: all test tests clean + +all: $(LIBNETCORE) $(LIBVTESSE) $(CLI) + +$(BUILD)/$(LWIP_DIR)/%.o: $(LWIP_DIR)/%.c + @mkdir -p $(dir $@) + $(CC) $(LWIP_CFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@ + +$(BUILD)/%.o: %.c + @mkdir -p $(dir $@) + $(CC) $(BASE_CFLAGS) $(CFLAGS) -MMD -MP -c $< -o $@ + +$(LIBNETCORE): $(NETCORE_OBJ) + $(AR) rcs $@ $^ + +$(LIBVTESSE): $(VTUN_OBJ) + $(AR) rcs $@ $^ + +$(CLI): $(BUILD)/cli/vtesse_cli.o $(LIBVTESSE) $(LIBNETCORE) + $(CC) $(BASE_LDFLAGS) $(LDFLAGS) -o $@ $^ + +$(BUILD)/test_%: $(BUILD)/tests/test_%.o $(LIBVTESSE) $(LIBNETCORE) + $(CC) $(BASE_LDFLAGS) $(LDFLAGS) -o $@ $^ + +tests: $(TESTS) + +test: $(TESTS) + @for t in $(TESTS); do echo "== $$t"; $$t || exit 1; done + +clean: + rm -rf $(BUILD) + +-include $(shell find $(BUILD) -name '*.d' 2>/dev/null) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..7e3d718 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +.gradle-cache/ +build/ +app/build/ +app/.cxx/ +local.properties +keystore.properties +*.jks +*.keystore diff --git a/android/.kotlin/errors/errors-1792001346003.log b/android/.kotlin/errors/errors-1792001346003.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1792001346003.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/.kotlin/errors/errors-1792001436810.log b/android/.kotlin/errors/errors-1792001436810.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1792001436810.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/.kotlin/errors/errors-1792001732067.log b/android/.kotlin/errors/errors-1792001732067.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1792001732067.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..2288196 --- /dev/null +++ b/android/README.md @@ -0,0 +1,81 @@ +# VTesse for Android + +VTun client for Android **without `VpnService`** (#13): a foreground service with a +regular notification runs the tunnel and the lwIP stack and offers a local SOCKS5 +proxy and TCP port forwards on `127.0.0.1`. Other apps use them directly, e.g. VLC +with `rtsp://127.0.0.1:8554/...` for a forward `8554:CAMERA_IP:554`. No VPN icon, no +VPN permission; Android 5.0 (API 21) and later, phones and Android TV boxes (TV +launcher banner, no touchscreen required). + +- Servers: up to three (name, VTun server, port, device ID, password), selected with + the buttons at the top. Selecting another server while connected disconnects and + connects with it; the notification shows `VTesse · Server N · name`. +- Cameras: up to 8 per server (name, IP). For every camera the checked services + are forwarded on fixed local ports, **local port = server·1000 + service·10 + + camera** (#27): + + | Service | Camera port | Server 1 | Server 2 | Server 3 | + |---|---|---|---|---| + | RTSP (55) | 554 | 1551–1558 | 2551–2558 | 3551–3558 | + | HTTP (80) | 80 | 1801–1808 | 2801–2808 | 3801–3808 | + | SSH (22), off by default | 22 | 1221–1228 | 2221–2228 | 3221–3228 | + | SOCKS5 | — | 1080 | 2080 | 3080 | + + Camera 3 of server 1 in a player: `rtsp://user:pass@127.0.0.1:1553/stream=0`; its + web UI: `http://127.0.0.1:1803/`. Anything else goes into *Other forwards* + (`LPORT:IP:RPORT`). +- RTP/UDP from cameras (#28): with *Receive RTP/UDP* checked, datagrams sent to the + device's tunnel address on port **S559** (1559, 2559, 3559 — "camera 9" of the RTSP + block) are relayed to `127.0.0.1:S559`. The status shows what to configure: on the + camera `outgoing` → `udp://:1559` (majestic.yaml: `server:` or + `servers:` depending on the Majestic version, see + [usage](../docs/usage.md#udp-relay-rtp-from-cameras)), in the player `udp://@:1559` + (OpenIPC Decoder: a `udp://` slot, shown as `UDP push`). The tunnel address stays + the same across reconnects. Verified on the Android TV box: a camera pushing H.264 + 1080p, Decoder playing it at full rate through the relay. +- Device ID: generated once (random locally administered unicast MAC, second + character `2`, `6`, `A` or `E`) and kept; it can be replaced by hand. Add it as a + session on the VTun server (long press copies it). +- The tunnel runs in its own process (`:tunnel`): the native stack can be started + once per process, so *Disconnect* ends that process. +- Network changes (Wi-Fi ↔ mobile) reconnect at once (`vt_network_changed()`); + a partial wake lock keeps keepalives going with the screen off. +- Native code: the repository's C sources built by CMake + ([`app/src/main/cpp`](app/src/main/cpp)); JNI bridge `vtesse_jni.c`. + +## Players + +Recommended: [OpenIPC Decoder](https://github.com/OpenIPC/decoder) with the slot's +transport set to **TCP** — RTP over UDP cannot pass a TCP port forward (Auto falls +back to TCP after 3 s). A camera can be opened from the shell: + +```sh +adb shell am start -a android.intent.action.SEND -t text/plain \ + --es android.intent.extra.TEXT 'rtsp://user:pass@127.0.0.1:1551/stream=0' \ + --es transport tcp -n com.openipc.decoder/.Decoder +``` + +Keep Decoder's **S** mode (key frames only) off for full-rate video. VLC works too +(`rtsp://127.0.0.1:1551/...`). Smooth 1080p through the tunnel was confirmed on an +Android TV box (#25); if a stream shows far fewer frames than the camera is set to, +check the camera's own uplink first (transmit drops in its `/metrics`): the tunnel +carries several Mbit/s per connection (#24). + +## Build + +```sh +android/build.sh # debug + release APK in Docker (ci/Dockerfile.android) +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +``` + +Release signing: `VTESSE_KEYSTORE` (path) with `VTESSE_KEYSTORE_PASSWORD`, +`VTESSE_KEY_ALIAS`, `VTESSE_KEY_PASSWORD` in the environment, or +`android/keystore.properties` (`storeFile`, `storePassword`, `keyAlias`, +`keyPassword`); `android/build.sh` also picks up a key kept outside the repository in +`~/.config/vtesse/`. CI signs with the repository secrets `VTESSE_KEYSTORE_BASE64`, +`VTESSE_KEYSTORE_PASSWORD`, `VTESSE_KEY_ALIAS`, `VTESSE_KEY_PASSWORD`; without a key +the release APK is unsigned and the release job refuses it. Keep a backup of the +key: updates must be signed with the same key. + +Release certificate (SHA-256): +`5B:EF:22:D7:BB:4F:1B:CB:DA:0F:4E:DE:AD:2B:6D:D7:18:C1:48:AA:F4:48:17:BB:69:2A:15:72:A8:FB:0D:E2` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..8d226f1 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,72 @@ +// VTesse for Android: VTun client with a local proxy, no VpnService (#13). +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +// Version from include/vtesse/version.h ("MAJOR.MM" -> versionCode MAJOR*100+MM). +val vtesseVersion: String = Regex("#define VTESSE_VERSION \"([0-9.]+)\"") + .find(rootProject.file("../include/vtesse/version.h").readText())!!.groupValues[1] + +// Release signing from android/keystore.properties (not in the repository) or env. +val keystoreProps = Properties().apply { + val f = rootProject.file("keystore.properties") + if (f.exists()) f.inputStream().use { load(it) } +} +fun signingValue(key: String, env: String): String? = keystoreProps.getProperty(key) ?: System.getenv(env) + +android { + namespace = "org.openipc.vtesse" + compileSdk = 35 + ndkVersion = "27.2.12479018" + buildToolsVersion = "35.0.0" + + defaultConfig { + applicationId = "org.openipc.vtesse" + minSdk = 21 + targetSdk = 35 + versionName = vtesseVersion + versionCode = vtesseVersion.split(".").let { it[0].toInt() * 100 + it[1].toInt() } + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + signingConfigs { + create("release") { + val store = signingValue("storeFile", "VTESSE_KEYSTORE") + if (store != null) { + storeFile = file(store) + storePassword = signingValue("storePassword", "VTESSE_KEYSTORE_PASSWORD") + keyAlias = signingValue("keyAlias", "VTESSE_KEY_ALIAS") + keyPassword = signingValue("keyPassword", "VTESSE_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + if (signingValue("storeFile", "VTESSE_KEYSTORE") != null) + signingConfig = signingConfigs.getByName("release") + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2883359 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# JNI entry points are looked up by name. +-keep class org.openipc.vtesse.Native { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..03d2741 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..b1d17ab --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +# libvtesse.so for the Android app: the VTun client, libnetcore (lwIP stack, +# local proxy) and the JNI bridge, built from the repository sources. +cmake_minimum_required(VERSION 3.22.1) +project(vtesse C) + +set(ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../..) +set(LWIP ${ROOT}/third_party/lwip/src) + +set(LWIP_SRC + ${LWIP}/core/def.c ${LWIP}/core/inet_chksum.c ${LWIP}/core/init.c ${LWIP}/core/ip.c + ${LWIP}/core/mem.c ${LWIP}/core/memp.c ${LWIP}/core/netif.c ${LWIP}/core/pbuf.c + ${LWIP}/core/stats.c ${LWIP}/core/sys.c ${LWIP}/core/tcp.c ${LWIP}/core/tcp_in.c + ${LWIP}/core/tcp_out.c ${LWIP}/core/timeouts.c ${LWIP}/core/udp.c + ${LWIP}/core/ipv4/dhcp.c ${LWIP}/core/ipv4/etharp.c ${LWIP}/core/ipv4/icmp.c + ${LWIP}/core/ipv4/ip4.c ${LWIP}/core/ipv4/ip4_addr.c ${LWIP}/core/ipv4/ip4_frag.c + ${LWIP}/netif/ethernet.c) + +set(CORE_SRC + ${ROOT}/netcore/tunnel.c ${ROOT}/netcore/loop.c ${ROOT}/netcore/route.c + ${ROOT}/netcore/stack.c ${ROOT}/netcore/proxy.c ${ROOT}/netcore/udprelay.c + ${ROOT}/vtun/vtun_proto.c ${ROOT}/vtun/vtun_client.c) + +add_library(vtesse SHARED vtesse_jni.c ${CORE_SRC} ${LWIP_SRC}) +target_include_directories(vtesse PRIVATE ${ROOT}/include ${ROOT}/port/lwip ${LWIP}/include) +target_compile_options(vtesse PRIVATE -std=gnu99 -Os -Wall -Wno-unused-parameter -Wno-address + -ffunction-sections -fdata-sections) +target_link_options(vtesse PRIVATE -Wl,--gc-sections) +target_link_libraries(vtesse PRIVATE log) diff --git a/android/app/src/main/cpp/vtesse_jni.c b/android/app/src/main/cpp/vtesse_jni.c new file mode 100644 index 0000000..868fdaa --- /dev/null +++ b/android/app/src/main/cpp/vtesse_jni.c @@ -0,0 +1,579 @@ +/* + * JNI bridge of the Android app. + * + * Runs the VTun client, the lwIP stack and the local proxy (SOCKS5, port + * forwards on 127.0.0.1) in one native thread: the libraries are + * single-threaded. Java talks to that thread through a pipe (stop, network + * changed) and reads a status snapshot kept under a mutex. + * + * One session per process: lwIP keeps global state and is initialised once, so + * the app runs the tunnel service in its own process and ends that process on + * stop. + * + * Configuration: the key=value lines of the vtesse-cli config file (host, port, + * session, password, ip, gw, route, udp_relay) plus socks_port, + * forward=LPORT:IP:RPORT (repeatable) and listen. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netcore/loop.h" +#include "netcore/proxy.h" +#include "netcore/route.h" +#include "netcore/stack.h" +#include "netcore/udprelay.h" +#include "vtesse/version.h" +#include "vtesse/vtun_client.h" + +#define TAG "vtesse" +#define LOG_LINES 40 +#define LOG_LINE_MAX 160 + +enum { ST_IDLE, ST_CONNECTING, ST_UP, ST_DOWN, ST_FAILED, ST_STOPPED }; +static const char *const state_names[] = { "idle", "connecting", "up", "down", "failed", "stopped" }; + +/* Shared with the Java threads; everything below the mutex. */ +static struct { + pthread_mutex_t mu; + int state; + int bound; + char addr[16], mask[16], gw[16]; + char routes[NC_ROUTES_MAX][48]; + int route_count; + char flags[32]; + char error[128]; + nc_proxy_stats_t ps; + nc_udp_relay_stats_t us[NC_UDP_RELAY_MAX]; + char log[LOG_LINES][LOG_LINE_MAX]; + int log_next, log_count; +} S = { .mu = PTHREAD_MUTEX_INITIALIZER }; + +/* Owned by the loop thread once it runs. */ +static pthread_t loop_thread; +static int started; +static int cmd_pipe[2] = { -1, -1 }; +static nc_loop_t loop; +static vt_t *vt; +static nc_proxy_t *px; +static nc_udp_relay_t *relay[NC_UDP_RELAY_MAX]; +static uint64_t t_start, next_stats; + +static void logf_(int prio, const char *fmt, ...) +{ + char msg[LOG_LINE_MAX - 12]; + va_list ap; + + va_start(ap, fmt); + vsnprintf(msg, sizeof(msg), fmt, ap); + va_end(ap); + __android_log_print(prio, TAG, "%s", msg); + pthread_mutex_lock(&S.mu); + snprintf(S.log[S.log_next], LOG_LINE_MAX, "[%7.1f] %s", + t_start ? (double)(nc_now_ms() - t_start) / 1000.0 : 0.0, msg); + S.log_next = (S.log_next + 1) % LOG_LINES; + if (S.log_count < LOG_LINES) + S.log_count++; + pthread_mutex_unlock(&S.mu); +} + +static void set_state(int state, int err) +{ + pthread_mutex_lock(&S.mu); + S.state = state; + if (err) + snprintf(S.error, sizeof(S.error), "%s", tunnel_strerror(err)); + else if (state == ST_UP) + S.error[0] = '\0'; + pthread_mutex_unlock(&S.mu); +} + +/* ---- events (loop thread) ---- */ + +static void on_bound(void) +{ + nc_stack_info_t in; + int i; + + nc_stack_get_info(&in); + pthread_mutex_lock(&S.mu); + S.bound = 1; + nc_ip4_format(in.addr, S.addr, sizeof(S.addr)); + nc_ip4_format(in.mask, S.mask, sizeof(S.mask)); + nc_ip4_format(in.gw, S.gw, sizeof(S.gw)); + S.route_count = in.routes.count; + for (i = 0; i < in.routes.count; i++) + nc_route_format(&in.routes.r[i], S.routes[i], sizeof(S.routes[i])); + pthread_mutex_unlock(&S.mu); + logf_(ANDROID_LOG_INFO, "address %s mask %s%s", S.addr, S.mask, + in.default_route ? " (default route)" : ""); + for (i = 0; i < in.routes.count; i++) + logf_(ANDROID_LOG_INFO, " route %s", S.routes[i]); +} + +static void on_stack_event(void *user, int ev, int tev, int err) +{ + (void)user; + if (ev == NC_STACK_EV_ADDR_BOUND) { + on_bound(); + } else if (ev == NC_STACK_EV_ADDR_LOST) { + pthread_mutex_lock(&S.mu); + S.bound = 0; + pthread_mutex_unlock(&S.mu); + logf_(ANDROID_LOG_INFO, "address lost"); + } else if (ev == NC_STACK_EV_TUNNEL) { + switch (tev) { + case TUNNEL_EV_CONNECTING: + set_state(ST_CONNECTING, 0); + logf_(ANDROID_LOG_INFO, "connecting"); + break; + case TUNNEL_EV_UP: { + char flags[32]; + + vt_flags_format(vt_session_flags(vt), flags, sizeof(flags)); + pthread_mutex_lock(&S.mu); + snprintf(S.flags, sizeof(S.flags), "%s", flags); + pthread_mutex_unlock(&S.mu); + set_state(ST_UP, 0); + logf_(ANDROID_LOG_INFO, "up: %s, flags %s", vt_greeting(vt), flags); + break; + } + case TUNNEL_EV_DOWN: + set_state(ST_DOWN, err); + logf_(ANDROID_LOG_WARN, "down: %s", tunnel_strerror(err)); + break; + case TUNNEL_EV_FAILED: + set_state(ST_FAILED, err); + logf_(ANDROID_LOG_ERROR, "failed: %s", tunnel_strerror(err)); + break; + } + } +} + +static void on_proxy_log(void *user, const char *msg) +{ + (void)user; + logf_(ANDROID_LOG_INFO, "proxy: %s", msg); +} + +/* ---- command pipe and stats timer (loop sources) ---- */ + +static int cmd_get_fd(void *ctx) +{ + (void)ctx; + return cmd_pipe[0]; +} + +static void cmd_on_readable(void *ctx) +{ + char buf[16]; + ssize_t n, i; + + (void)ctx; + while ((n = read(cmd_pipe[0], buf, sizeof(buf))) > 0) + for (i = 0; i < n; i++) { + if (buf[i] == 'q') + loop.stop = 1; + else if (buf[i] == 'n') { + logf_(ANDROID_LOG_INFO, "network changed"); + vt_network_changed(vt); + } + } +} + +static int stats_next(void *ctx) +{ + uint64_t now = nc_now_ms(); + + (void)ctx; + return now >= next_stats ? 0 : (int)(next_stats - now); +} + +static void stats_on_timer(void *ctx) +{ + (void)ctx; + next_stats = nc_now_ms() + 1000; + pthread_mutex_lock(&S.mu); + if (px) + nc_proxy_get_stats(px, &S.ps); + for (int i = 0; i < NC_UDP_RELAY_MAX; i++) + if (relay[i]) + nc_udp_relay_get_stats(relay[i], &S.us[i]); + pthread_mutex_unlock(&S.mu); +} + +static void *loop_main(void *arg) +{ + tunnel_t t = vt_tunnel(vt); + + (void)arg; + vt_start(vt); + nc_loop_run(&loop, 0); + + t.ops->close(t.ctx); /* CONN_CLOSE to the server */ + if (px) + nc_proxy_destroy(px); + px = NULL; + for (int i = 0; i < NC_UDP_RELAY_MAX; i++) { + nc_udp_relay_destroy(relay[i]); + relay[i] = NULL; + } + vt_destroy(vt); + vt = NULL; + set_state(ST_STOPPED, 0); + logf_(ANDROID_LOG_INFO, "stopped"); + return NULL; +} + +/* ---- configuration ---- */ + +static int parse_ip4(const char *s, uint32_t *out) +{ + unsigned a, b, c, d; + char tail; + + if (sscanf(s, "%u.%u.%u.%u%c", &a, &b, &c, &d, &tail) != 4 || a > 255 || b > 255 || c > 255 || d > 255) + return -1; + *out = a << 24 | b << 16 | c << 8 | d; + return 0; +} + +static int parse_prefix(const char *s, uint32_t *addr, int *prefix) +{ + char buf[32]; + const char *slash = strchr(s, '/'); + + if (!slash || (size_t)(slash - s) >= sizeof(buf)) + return -1; + memcpy(buf, s, (size_t)(slash - s)); + buf[slash - s] = '\0'; + *prefix = atoi(slash + 1); + return *prefix < 0 || *prefix > 32 || parse_ip4(buf, addr) < 0 ? -1 : 0; +} + +static int parse_forward(const char *s, nc_forward_t *f) +{ + unsigned lp, rp; + char ip[32]; + + if (sscanf(s, "%u:%31[0-9.]:%u", &lp, ip, &rp) != 3 || !lp || lp > 65535 || !rp || rp > 65535) + return -1; + f->local_port = (uint16_t)lp; + f->port = (uint16_t)rp; + return parse_ip4(ip, &f->ip); +} + +static char *trim(char *s) +{ + char *e; + + while (*s == ' ' || *s == '\t') + s++; + e = s + strlen(s); + while (e > s && (e[-1] == ' ' || e[-1] == '\t' || e[-1] == '\r' || e[-1] == '\n')) + *--e = '\0'; + return s; +} + +typedef struct app_cfg { + char host[256], session[64], password[128], listen[64]; + int port, socks_port; + uint16_t relay_port[NC_UDP_RELAY_MAX], relay_local[NC_UDP_RELAY_MAX]; + int nrelays; + vt_cfg_t vc; + nc_stack_cfg_t sc; + nc_proxy_cfg_t pc; +} app_cfg_t; + +static app_cfg_t cfg; /* strings referenced by vt (copied) and the proxy (kept) */ + +/* Fills cfg from key=value text. NULL on success, else a message. */ +static const char *parse_config(char *text) +{ + static char msg[128]; + char *line, *save = NULL; + + memset(&cfg, 0, sizeof(cfg)); + for (line = strtok_r(text, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) { + char *k = trim(line), *v = strchr(k, '='); + + if (*k == '#' || !v) + continue; + *v++ = '\0'; + k = trim(k); + v = trim(v); + if (!strcmp(k, "host")) + snprintf(cfg.host, sizeof(cfg.host), "%s", v); + else if (!strcmp(k, "port")) + cfg.port = atoi(v); + else if (!strcmp(k, "session")) + snprintf(cfg.session, sizeof(cfg.session), "%s", v); + else if (!strcmp(k, "password")) + snprintf(cfg.password, sizeof(cfg.password), "%s", v); + else if (!strcmp(k, "listen")) + snprintf(cfg.listen, sizeof(cfg.listen), "%s", v); + else if (!strcmp(k, "socks_port")) + cfg.socks_port = atoi(v); + else if (!strcmp(k, "forward")) { + if (cfg.pc.forward_count >= NC_PROXY_MAX_FORWARDS || + parse_forward(v, &cfg.pc.forwards[cfg.pc.forward_count]) < 0) { + snprintf(msg, sizeof(msg), "bad forward '%s' (LPORT:A.B.C.D:RPORT, max %d)", v, + NC_PROXY_MAX_FORWARDS); + return msg; + } + cfg.pc.forward_count++; + } else if (!strcmp(k, "udp_relay")) { + if (cfg.nrelays >= NC_UDP_RELAY_MAX || + nc_udp_relay_parse(v, &cfg.relay_port[cfg.nrelays], &cfg.relay_local[cfg.nrelays]) < 0) { + snprintf(msg, sizeof(msg), "bad udp_relay '%s' (PORT[:LOCAL_PORT], max %d)", v, NC_UDP_RELAY_MAX); + return msg; + } + cfg.nrelays++; + } else if (!strcmp(k, "ip")) { + int p; + + if (parse_prefix(v, &cfg.sc.addr, &p) < 0) + return "bad ip (A.B.C.D/P)"; + cfg.sc.mask = p ? 0xffffffffu << (32 - p) : 0; + cfg.sc.static_ip = 1; + } else if (!strcmp(k, "gw")) { + if (parse_ip4(v, &cfg.sc.gw) < 0) + return "bad gw (A.B.C.D)"; + } else if (!strcmp(k, "route")) { + uint32_t net; + int p; + + if (parse_prefix(v, &net, &p) < 0 || nc_routes_add(&cfg.sc.routes, net, (uint8_t)p, 0) < 0) + return "bad route (NET/P)"; + } + } + if (!cfg.host[0] || cfg.port <= 0 || cfg.port > 65535) + return "server and port are required"; + if (nc_devid_to_mac(cfg.session, cfg.sc.mac) < 0) + return "device ID: 12 characters 0-9A-F, unicast"; + if (!cfg.password[0]) + return "password is required"; + if (cfg.socks_port < 0 || cfg.socks_port > 65535) + return "bad SOCKS5 port"; + if (!cfg.socks_port && !cfg.pc.forward_count && !cfg.nrelays) + return "nothing to do: no SOCKS5, port forward or UDP relay"; + /* routes added before gw= is known: set their gateway now */ + { + int i; + + for (i = 0; i < cfg.sc.routes.count; i++) + cfg.sc.routes.r[i].gw = cfg.sc.gw; + } + return NULL; +} + +/* ---- JNI ---- */ + +static jstring jerr(JNIEnv *env, const char *msg) +{ + return (*env)->NewStringUTF(env, msg); +} + +JNIEXPORT jstring JNICALL +Java_org_openipc_vtesse_Native_start(JNIEnv *env, jclass cls, jstring jconfig) +{ + const char *utf; + char *text; + const char *msg; + nc_source_t src; + tunnel_t t; + int err; + + (void)cls; + if (started) + return jerr(env, "already started in this process"); + utf = (*env)->GetStringUTFChars(env, jconfig, NULL); + text = utf ? strdup(utf) : NULL; + if (utf) + (*env)->ReleaseStringUTFChars(env, jconfig, utf); + if (!text) + return jerr(env, "out of memory"); + t_start = nc_now_ms(); + msg = parse_config(text); + memset(text, 0, strlen(text)); /* password */ + free(text); + if (msg) + return jerr(env, msg); + + cfg.vc.host = cfg.host; + cfg.vc.port = (uint16_t)cfg.port; + cfg.vc.session = cfg.session; + cfg.vc.password = cfg.password; + cfg.vc.auth = VT_AUTH_XOR; + vt = vt_create(&cfg.vc, &err); + memset(cfg.password, 0, sizeof(cfg.password)); + if (!vt) + return jerr(env, tunnel_strerror(err)); + t = vt_tunnel(vt); + + nc_loop_init(&loop); + src = nc_source_from_tunnel(&t); + nc_loop_add(&loop, &src); + if (nc_stack_init(&t, &cfg.sc, on_stack_event, NULL) < 0) { + vt_destroy(vt); + return jerr(env, "stack initialisation failed"); + } + src = nc_stack_timer_source(); + nc_loop_add(&loop, &src); + + { + int i; + char rmsg[128]; + + for (i = 0; i < cfg.nrelays; i++) { + relay[i] = nc_udp_relay_create(cfg.relay_port[i], NULL, cfg.relay_local[i], rmsg, sizeof(rmsg)); + if (!relay[i]) { + vt_destroy(vt); + return jerr(env, rmsg); + } + logf_(ANDROID_LOG_INFO, "udp relay :%u -> 127.0.0.1:%u", (unsigned)cfg.relay_port[i], + (unsigned)cfg.relay_local[i]); + } + } + + cfg.pc.listen_addr = cfg.listen[0] ? cfg.listen : "127.0.0.1"; + cfg.pc.socks_port = (uint16_t)cfg.socks_port; + if (cfg.socks_port || cfg.pc.forward_count) { + px = nc_proxy_create(&cfg.pc, on_proxy_log, NULL); + if (!px) { + vt_destroy(vt); + return jerr(env, "cannot open the local ports (in use?)"); + } + src = nc_proxy_source(px); + nc_loop_add(&loop, &src); + } + + if (pipe(cmd_pipe) < 0) { + nc_proxy_destroy(px); + vt_destroy(vt); + return jerr(env, strerror(errno)); + } + fcntl(cmd_pipe[0], F_SETFL, fcntl(cmd_pipe[0], F_GETFL) | O_NONBLOCK); + memset(&src, 0, sizeof(src)); + src.get_fd = cmd_get_fd; + src.on_readable = cmd_on_readable; + nc_loop_add(&loop, &src); + memset(&src, 0, sizeof(src)); + src.next_timeout_ms = stats_next; + src.on_timer = stats_on_timer; + nc_loop_add(&loop, &src); + + if (cfg.socks_port) + logf_(ANDROID_LOG_INFO, "socks5 on %s:%d", cfg.pc.listen_addr, cfg.socks_port); + { + int i; + + for (i = 0; i < cfg.pc.forward_count; i++) { + char ip[16]; + + nc_ip4_format(cfg.pc.forwards[i].ip, ip, sizeof(ip)); + logf_(ANDROID_LOG_INFO, "forward %s:%u -> %s:%u", cfg.pc.listen_addr, + (unsigned)cfg.pc.forwards[i].local_port, ip, (unsigned)cfg.pc.forwards[i].port); + } + } + set_state(ST_CONNECTING, 0); + if (pthread_create(&loop_thread, NULL, loop_main, NULL) != 0) { + nc_proxy_destroy(px); + vt_destroy(vt); + return jerr(env, "cannot start the network thread"); + } + started = 1; + return NULL; +} + +static void send_cmd(char c) +{ + if (started && cmd_pipe[1] >= 0) + (void)!write(cmd_pipe[1], &c, 1); +} + +JNIEXPORT void JNICALL +Java_org_openipc_vtesse_Native_stop(JNIEnv *env, jclass cls) +{ + (void)env; + (void)cls; + if (!started) + return; + send_cmd('q'); + pthread_join(loop_thread, NULL); + started = 0; +} + +JNIEXPORT void JNICALL +Java_org_openipc_vtesse_Native_networkChanged(JNIEnv *env, jclass cls) +{ + (void)env; + (void)cls; + send_cmd('n'); +} + +/* key=value lines; "route" and "log" repeat. */ +JNIEXPORT jstring JNICALL +Java_org_openipc_vtesse_Native_status(JNIEnv *env, jclass cls) +{ + static char buf[16384]; + size_t n = 0; + int i; + + (void)cls; +#define PUT(...) do { if (n < sizeof(buf)) n += (size_t)snprintf(buf + n, sizeof(buf) - n, __VA_ARGS__); } while (0) + pthread_mutex_lock(&S.mu); + PUT("state=%s\n", state_names[S.state]); + PUT("bound=%d\n", S.bound); + if (S.bound) { + PUT("addr=%s\nmask=%s\ngw=%s\n", S.addr, S.mask, S.gw); + for (i = 0; i < S.route_count; i++) + PUT("route=%s\n", S.routes[i]); + } + if (S.flags[0]) + PUT("flags=%s\n", S.flags); + if (S.error[0]) + PUT("error=%s\n", S.error); + PUT("accepted=%lu\nactive=%lu\nfailed=%lu\nrejected=%lu\nup=%llu\ndown=%llu\n", + S.ps.accepted, S.ps.active, S.ps.failed, S.ps.rejected, S.ps.bytes_up, S.ps.bytes_down); + /* udp=PORT,LOCAL_PORT,datagrams,bytes,dropped */ + for (i = 0; i < cfg.nrelays && i < NC_UDP_RELAY_MAX; i++) + PUT("udp=%u,%u,%lu,%llu,%lu\n", (unsigned)cfg.relay_port[i], (unsigned)cfg.relay_local[i], + S.us[i].datagrams, S.us[i].bytes, S.us[i].dropped); + for (i = 0; i < S.log_count; i++) + PUT("log=%s\n", S.log[(S.log_next - S.log_count + i + LOG_LINES) % LOG_LINES]); + pthread_mutex_unlock(&S.mu); +#undef PUT + if (n >= sizeof(buf)) + buf[sizeof(buf) - 1] = '\0'; + return (*env)->NewStringUTF(env, buf); +} + +JNIEXPORT jstring JNICALL +Java_org_openipc_vtesse_Native_version(JNIEnv *env, jclass cls) +{ + (void)cls; + return (*env)->NewStringUTF(env, VTESSE_VERSION); +} + +/* Device ID check for the settings screen: NULL if valid, else a message. */ +JNIEXPORT jstring JNICALL +Java_org_openipc_vtesse_Native_checkDeviceId(JNIEnv *env, jclass cls, jstring jid) +{ + const char *id = (*env)->GetStringUTFChars(env, jid, NULL); + uint8_t mac[6]; + int ok = id && nc_devid_to_mac(id, mac) == 0; + + (void)cls; + if (id) + (*env)->ReleaseStringUTFChars(env, jid, id); + return ok ? NULL : jerr(env, "12 characters 0-9A-F, first byte unicast"); +} diff --git a/android/app/src/main/java/org/openipc/vtesse/MainActivity.kt b/android/app/src/main/java/org/openipc/vtesse/MainActivity.kt new file mode 100644 index 0000000..fde744d --- /dev/null +++ b/android/app/src/main/java/org/openipc/vtesse/MainActivity.kt @@ -0,0 +1,349 @@ +package org.openipc.vtesse + +import android.Manifest +import android.app.Activity +import android.app.ActivityManager +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast + +/** + * Servers (up to 3) with their cameras (up to 8 each), settings and status + * (#26, #27). The tunnel itself runs in [TunnelService] (":tunnel" process). + */ +class MainActivity : Activity() { + private class CameraRow(val name: EditText, val ip: EditText, val ports: TextView) + + private lateinit var serverButtons: List