diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b07ddb3..2509b0b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,9 +1,9 @@ name: Android -# Builds the Android APKs: the Rust server plus the EasyTier and frp tunnel -# clients, all cross-compiled from source with the NDK, packed into a small -# app that runs them as a foreground service. -# 构建安卓 APK(手机上运行的 VIVANT 服务器 + EasyTier / frp 内网穿透)。 +# Builds the Android APKs: the Rust server and the EasyTier / frp clients +# cross-compiled from source with the NDK, plus Tor (tor-android, as in +# Orbot), packed into a small app that runs them as a foreground service. +# 构建安卓 APK(手机上运行的 VIVANT 服务器 + Tor / EasyTier / frp)。 # # Stable signing (so updates install over each other): add the repository # secrets ANDROID_KEYSTORE_BASE64 (base64 of a .jks), ANDROID_KEYSTORE_PASSWORD, @@ -44,7 +44,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: android/.tunnel-src/easytier -> target - key: easytier-${{ env.EASYTIER_VERSION }}-${{ matrix.abi }} + key: easytier-${{ env.EASYTIER_VERSION }}-${{ matrix.abi }}-${{ hashFiles('android/easytier-no-ip.patch') }} - name: Build run: | set -o pipefail @@ -75,14 +75,14 @@ jobs: run: | set -o pipefail android/build-tunnels.sh frpc 2>&1 | tee build.log - file android/app/src/main/jniLibs/*/libfrpc.so + file android/app/src/full/jniLibs/*/libfrpc.so - name: Report failure if: failure() run: .github/report-failure.sh build.log - uses: actions/upload-artifact@v4 with: name: frpc - path: android/app/src/main/jniLibs/ + path: android/app/src/full/jniLibs/ if-no-files-found: error apk: @@ -94,7 +94,8 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: "17" + # tor-android's classes are Java 24 bytecode + java-version: "25" - uses: dtolnay/rust-toolchain@stable with: @@ -126,13 +127,14 @@ jobs: J=android/app/src/main/jniLibs for abi in arm64-v8a armeabi-v7a x86_64; do cp tunnels/easytier-$abi/libeasytier.so $J/$abi/ - cp tunnels/frpc/$abi/libfrpc.so $J/$abi/ + mkdir -p android/app/src/full/jniLibs/$abi + cp tunnels/frpc/$abi/libfrpc.so android/app/src/full/jniLibs/$abi/ done - ls -l $J/* + ls -l $J/* android/app/src/full/jniLibs/* - uses: gradle/actions/setup-gradle@v4 with: - gradle-version: "8.14.3" + gradle-version: "9.8.2" - name: Signing key env: @@ -157,18 +159,26 @@ jobs: run: | set -o pipefail gradle -p android --no-daemon assembleRelease 2>&1 | tee build.log - d=android/app/build/outputs/apk/release + d=android/app/build/outputs/apk + ls -R $d mkdir -p out - cp $d/app-universal-release.apk out/vivant-android.apk - cp $d/app-arm64-v8a-release.apk out/vivant-android-arm64.apk - cp $d/app-armeabi-v7a-release.apk out/vivant-android-armv7.apk - cp $d/app-x86_64-release.apk out/vivant-android-x86_64.apk - for f in libvivant.so libeasytier.so libfrpc.so; do - for abi in arm64-v8a armeabi-v7a x86_64; do - unzip -l out/vivant-android.apk | grep -q "lib/$abi/$f" || { echo "missing $abi/$f"; exit 1; } - done - unzip -l out/vivant-android-arm64.apk | grep -q "lib/arm64-v8a/$f" || { echo "arm64 apk missing $f"; exit 1; } + # full edition (Tor + EasyTier + FRP) + cp $d/full/release/app-full-universal-release.apk out/vivant-android.apk + cp $d/full/release/app-full-arm64-v8a-release.apk out/vivant-android-arm64.apk + cp $d/full/release/app-full-armeabi-v7a-release.apk out/vivant-android-armv7.apk + cp $d/full/release/app-full-x86_64-release.apk out/vivant-android-x86_64.apk + # EasyTier-only edition + cp $d/easytier/release/app-easytier-universal-release.apk out/vivant-android-easytier.apk + cp $d/easytier/release/app-easytier-arm64-v8a-release.apk out/vivant-android-easytier-arm64.apk + cp $d/easytier/release/app-easytier-armeabi-v7a-release.apk out/vivant-android-easytier-armv7.apk + cp $d/easytier/release/app-easytier-x86_64-release.apk out/vivant-android-easytier-x86_64.apk + has() { unzip -l "$1" | grep -q "lib/$2/$3"; } + for abi in arm64-v8a armeabi-v7a x86_64; do + for f in libvivant.so libeasytier.so libfrpc.so libtor.so; do has out/vivant-android.apk $abi $f || { echo "full: missing $abi/$f"; exit 1; }; done + for f in libvivant.so libeasytier.so; do has out/vivant-android-easytier.apk $abi $f || { echo "easytier: missing $abi/$f"; exit 1; }; done + for f in libfrpc.so libtor.so; do ! has out/vivant-android-easytier.apk $abi $f || { echo "easytier edition must not contain $f"; exit 1; }; done done + has out/vivant-android-easytier-arm64.apk arm64-v8a libeasytier.so ls -l out - name: Report failure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab3a37c..204b486 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,7 +152,9 @@ jobs: Windows / macOS:`vivant-windows-*.exe`、`vivant-macos-*` 只适合本机测试或局域网,没有安装器、沙箱和开机自启 · for local testing or a LAN only — no installer, sandbox or autostart. - 安卓手机当服务器(含 EasyTier / FRP 外网访问)· Android phone as a server (with EasyTier / FRP internet access): 大多数手机下载 `vivant-android-arm64.apk`,不确定就下 `vivant-android.apk`(通用版)· most phones: `vivant-android-arm64.apk`; not sure: `vivant-android.apk` (universal)(Android 8+,见 [docs/android.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.md) · see [docs/android.en.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.en.md)) + 安卓手机当服务器(Tor 洋葱服务 / EasyTier / FRP,不暴露 IP)· Android phone as a server (Tor onion service / EasyTier / FRP, no exposed IPs): 大多数手机下载 `vivant-android-arm64.apk`,不确定就下 `vivant-android.apk`(通用版)· most phones: `vivant-android-arm64.apk`; not sure: `vivant-android.apk` (universal)(Android 8+,见 [docs/android.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.md) · see [docs/android.en.md](https://github.com/Freecode100Year/vivant/blob/main/docs/android.en.md)) + + 只要 EasyTier 的精简版(不含 Tor / FRP,更小)· EasyTier-only edition (no Tor / FRP, smaller): `vivant-android-easytier-arm64.apk`,通用版 · universal `vivant-android-easytier.apk` 程序 Binaries: `vivant-linux-amd64` (x86_64), `vivant-linux-arm64` (ARM64, e.g. Oracle Ampere), `vivant-linux-armv7`;用 `SHA256SUMS` 校验 · verify with `SHA256SUMS`. (`ptterm-linux-*` are the same files under the old names.) EOF diff --git a/README.en.md b/README.en.md index 1c5197b..dbeca24 100644 --- a/README.en.md +++ b/README.en.md @@ -68,7 +68,7 @@ Manage it with `sudo vivant-ctl status | upgrade | uninstall`. If people can't c **Server can't reach GitHub:** download the offline one-click installer from the release page and run `bash vivant--linux-offline.sh --to root@SERVER` on your computer, see [Offline install](docs/deploy.en.md#offline-install-server-cant-reach-github). For a temporary server on Windows / macOS (testing, LAN), the release page has those binaries too, see [Deployment and configuration](docs/deploy.en.md#windows--macos). -**An Android phone as the server:** install `vivant-android-arm64.apk`, tap "Start", and people on the same Wi-Fi or the phone's hotspot can join; EasyTier or FRP lets people elsewhere in too, see [Android phone as a server](docs/android.en.md). +**An Android phone as the server:** install `vivant-android-arm64.apk`, tap "Start", and people on the same Wi-Fi or the phone's hotspot can join; a Tor onion service lets people elsewhere in without anyone seeing an IP, see [Android phone as a server](docs/android.en.md). **Upgrading from Private-Talk Terminal:** VIVANT used to be called Private-Talk Terminal (`ptterm`). Run the command above once on the old server and it migrates automatically, keeping the host key, configuration, bans and onion address. See [Deployment and configuration](docs/deploy.en.md#migrating-from-private-talk-terminal). @@ -95,7 +95,7 @@ Improvements chosen after studying similar projects (ssh-chat, devzat, SoftRoom, ## Documentation -- [Android phone as a server](docs/android.en.md): installing the APK, hotspot / LAN connections, building it +- [Android phone as a server](docs/android.en.md): installing the APK, Tor onion service (no exposed IPs), hotspot / LAN, building it - [Connecting and using](docs/usage.en.md): every platform, iPhone step by step, all commands, Tor - [AI agents](docs/agents.en.md): `bot@` JSON mode, example program - [Security and anonymity](docs/security.en.md): what the operator can see and what can't be promised diff --git a/README.md b/README.md index e1be976..1842fb2 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ curl -fsSL https://raw.githubusercontent.com/Freecode100Year/vivant/main/install **服务器连不上 GitHub:** 下载发布页里的离线一键安装包,在自己电脑上运行 `bash vivant-<版本>-linux-offline.sh --to root@服务器` 即可部署,见[离线安装](docs/deploy.md#离线安装服务器连不上-github)。想在 Windows / macOS 上临时跑一个(测试、局域网),发布页也有对应的程序,见[部署与配置](docs/deploy.md#windows--macos)。 -**用安卓手机当服务器:** 装上 `vivant-android-arm64.apk`,点一下「启动」,同一 Wi-Fi 或手机热点下的人就能连进来;还能用 EasyTier 或 FRP 让外网的人进来,见[安卓手机当服务器](docs/android.md)。 +**用安卓手机当服务器:** 装上 `vivant-android-arm64.apk`,点一下「启动」,同一 Wi-Fi 或手机热点下的人就能连进来;还能通过 Tor 洋葱服务让外网的人进来,谁都看不到 IP,见[安卓手机当服务器](docs/android.md)。 **从 Private-Talk Terminal 升级:** VIVANT 以前叫 Private-Talk Terminal(`ptterm`)。在旧服务器上运行一次上面的命令即可自动迁移,主机密钥、配置、封禁、洋葱地址都会保留,详见[部署与配置](docs/deploy.md#从-private-talk-terminal-迁移)。 @@ -98,7 +98,7 @@ curl -fsSL https://raw.githubusercontent.com/Freecode100Year/vivant/main/install - [连接与使用](docs/usage.md):各平台连接方法、iPhone 详细步骤、全部命令、Tor - [AI 智能体接入](docs/agents.md):`bot@` JSON 模式、示例程序 - [安全与匿名性](docs/security.md):部署者能看到什么、不能保证什么 -- [安卓手机当服务器](docs/android.md):APK 安装、热点 / 局域网连接、自己构建 +- [安卓手机当服务器](docs/android.md):APK 安装、Tor 洋葱服务(不暴露 IP)、热点 / 局域网、自己构建 - [部署与配置](docs/deploy.md):一键安装参数、迁移、管理员、配置项、Docker - [性能](docs/performance.md):压测数据和设计 - [开发](docs/development.md):构建、测试、发布、代码结构、路线图 diff --git a/android/.gitignore b/android/.gitignore index 6e67b5f..a647d06 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,4 +5,5 @@ local.properties *.keystore # built by build-native.sh app/src/main/jniLibs/ +app/src/full/jniLibs/ .tunnel-src/ diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 14f86ed..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,75 +0,0 @@ -plugins { - id "com.android.application" -} - -// Version comes from the repository's VERSION file (e.g. 0.11.4), or from -// VIVANT_VERSION (v0.11.4) in CI, so the APK always matches the server. -def rawVersion = (System.getenv("VIVANT_VERSION") ?: rootProject.file("../VERSION").text).trim().replaceFirst(/^v/, "") -def nums = (rawVersion =~ /^(\d+)\.(\d+)\.(\d+)/) -def code = nums.find() ? (nums.group(1).toInteger() * 10000 + nums.group(2).toInteger() * 100 + nums.group(3).toInteger()) : 1 - -android { - namespace "io.github.freecode100year.vivant" - compileSdk 35 - - defaultConfig { - applicationId "io.github.freecode100year.vivant" - minSdk 26 - targetSdk 35 - versionCode code - versionName rawVersion - } - - // Release signing: set VIVANT_KEYSTORE (path), VIVANT_KEYSTORE_PASSWORD, - // VIVANT_KEY_ALIAS, VIVANT_KEY_PASSWORD. Without them the release APK is - // signed with the debug key (fine for trying it out; updates need the - // same key every time). - def ks = System.getenv("VIVANT_KEYSTORE") - signingConfigs { - release { - if (ks) { - storeFile file(ks) - storePassword System.getenv("VIVANT_KEYSTORE_PASSWORD") - keyAlias System.getenv("VIVANT_KEY_ALIAS") - keyPassword System.getenv("VIVANT_KEY_PASSWORD") ?: System.getenv("VIVANT_KEYSTORE_PASSWORD") - } - } - } - - buildTypes { - release { - minifyEnabled false - signingConfig ks ? signingConfigs.release : signingConfigs.debug - } - } - - // One APK per CPU type keeps downloads small (the server plus two tunnel - // tools are ~40 MB per type); the universal one works everywhere. - splits { - abi { - enable true - reset() - include "arm64-v8a", "armeabi-v7a", "x86_64" - universalApk true - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - // The server is a real executable (libvivant.so) that the app starts as a - // child process; it must be extracted to nativeLibraryDir, the only place - // an app may execute files from. - packaging { - jniLibs { - useLegacyPackaging true - } - } - - lint { - abortOnError false - checkReleaseBuilds false - } -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b4ae8d1 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,105 @@ +import com.android.build.api.dsl.ApplicationExtension + +plugins { + id("com.android.application") +} + +// Version comes from the repository's VERSION file (e.g. 0.11.4), or from +// VIVANT_VERSION (v0.11.4) in CI, so the APK always matches the server. +val rawVersion = (System.getenv("VIVANT_VERSION") ?: rootProject.file("../VERSION").readText()) + .trim().removePrefix("v") +val versionNums = Regex("""^(\d+)\.(\d+)\.(\d+)""").find(rawVersion)?.destructured +val code = versionNums?.let { (a, b, c) -> a.toInt() * 10000 + b.toInt() * 100 + c.toInt() } ?: 1 + +configure { + namespace = "io.github.freecode100year.vivant" + // tor-android is built against API 37.1 + compileSdk { + version = release(37) { + minorApiLevel = 2 + } + } + + defaultConfig { + applicationId = "io.github.freecode100year.vivant" + minSdk = 26 + targetSdk = 35 + versionCode = code + versionName = rawVersion + } + + // Release signing: set VIVANT_KEYSTORE (path), VIVANT_KEYSTORE_PASSWORD, + // VIVANT_KEY_ALIAS, VIVANT_KEY_PASSWORD. Without them the release APK is + // signed with the debug key (fine for trying it out; updates need the + // same key every time). + val ks = System.getenv("VIVANT_KEYSTORE") + signingConfigs { + create("release") { + if (!ks.isNullOrEmpty()) { + storeFile = file(ks) + storePassword = System.getenv("VIVANT_KEYSTORE_PASSWORD") + keyAlias = System.getenv("VIVANT_KEY_ALIAS") + keyPassword = System.getenv("VIVANT_KEY_PASSWORD") ?: System.getenv("VIVANT_KEYSTORE_PASSWORD") + } + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName(if (ks.isNullOrEmpty()) "debug" else "release") + } + } + + // Two editions: "full" (Tor onion service, EasyTier, FRP) and "easytier" + // (EasyTier only: smaller, simpler). They install side by side. + flavorDimensions += "edition" + productFlavors { + create("full") { + dimension = "edition" + } + create("easytier") { + dimension = "edition" + applicationIdSuffix = ".easytier" + versionNameSuffix = "-easytier" + } + } + + // One APK per CPU type keeps downloads small; the universal one works everywhere. + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "armeabi-v7a", "x86_64") + isUniversalApk = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // The server and tunnel clients are real executables (lib*.so) started as + // child processes; they must be extracted to nativeLibraryDir, the only + // place an app may execute files from. + packaging { + jniLibs { + useLegacyPackaging = true + excludes += "lib/x86/**" // tor-android ships 32-bit x86 too; nobody needs it + } + } + + lint { + abortOnError = false + checkReleaseBuilds = false + } +} + +dependencies { + // full edition only: Tor itself (the same build Orbot uses) and its control-port library + add("fullImplementation", "info.guardianproject:tor-android:0.4.9.14") + add("fullImplementation", "info.guardianproject:jtorctl:0.4.5.7") + // Snowflake / obfs4 / webtunnel bridges for censored networks (as in Orbot) + add("fullImplementation", "com.netzarchitekten:IPtProxy:5.7.0") +} diff --git a/android/app/src/easytier/java/io/github/freecode100year/vivant/TorOnion.java b/android/app/src/easytier/java/io/github/freecode100year/vivant/TorOnion.java new file mode 100644 index 0000000..187d8b6 --- /dev/null +++ b/android/app/src/easytier/java/io/github/freecode100year/vivant/TorOnion.java @@ -0,0 +1,24 @@ +package io.github.freecode100year.vivant; + +import android.content.Context; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** EasyTier-only edition: no Tor inside. Same API as the full edition's TorOnion, doing nothing. */ +final class TorOnion { + static final String BRIDGES_NONE = "none", BRIDGES_SNOWFLAKE = "snowflake", BRIDGES_CUSTOM = "custom"; + + TorOnion(Context ctx) {} + + static File serviceDir(Context c) { return new File(c.getFilesDir(), "onion"); } + + static String address(Context c) { return null; } + + static List parseBridges(String text) { return new ArrayList<>(); } + + void start(int localPort) { throw new UnsupportedOperationException("this edition has no Tor"); } + + void stop() {} +} diff --git a/android/app/src/easytier/res/values/edition.xml b/android/app/src/easytier/res/values/edition.xml new file mode 100644 index 0000000..89af22f --- /dev/null +++ b/android/app/src/easytier/res/values/edition.xml @@ -0,0 +1,6 @@ + + + true + VIVANT ET + 外网访问:EasyTier(不记录 IP)· Internet access: EasyTier (no IPs kept) + diff --git a/android/app/src/full/assets/snowflake-bridges.txt b/android/app/src/full/assets/snowflake-bridges.txt new file mode 100644 index 0000000..72ce887 --- /dev/null +++ b/android/app/src/full/assets/snowflake-bridges.txt @@ -0,0 +1,4 @@ +# Tor Browser's built-in Snowflake bridges (as shipped by Orbot, builtin-bridges.json). +# Tor Browser 内置的 Snowflake 网桥。 +snowflake 192.0.2.4:80 8838024498816A039FCBBAB14E6F40A0843051FA fingerprint=8838024498816A039FCBBAB14E6F40A0843051FA url=https://1098762253.rsc.cdn77.org/ fronts=app.datapacket.com,www.datapacket.com ice=stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.telnyx.com:3478,stun:stun.hot-chilli.net:3478,stun:stun.fitauto.ru:3478,stun:stun.m-online.net:3478 utls-imitate=hellorandomizedalpn +snowflake 192.0.2.3:80 2B280B23E1107BB62ABFC40DDCC8824814F80A72 fingerprint=2B280B23E1107BB62ABFC40DDCC8824814F80A72 url=https://1098762253.rsc.cdn77.org/ fronts=app.datapacket.com,www.datapacket.com ice=stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.telnyx.com:3478,stun:stun.hot-chilli.net:3478,stun:stun.fitauto.ru:3478,stun:stun.m-online.net:3478 utls-imitate=hellorandomizedalpn diff --git a/android/app/src/full/java/io/github/freecode100year/vivant/TorOnion.java b/android/app/src/full/java/io/github/freecode100year/vivant/TorOnion.java new file mode 100644 index 0000000..324a6fc --- /dev/null +++ b/android/app/src/full/java/io/github/freecode100year/vivant/TorOnion.java @@ -0,0 +1,311 @@ +package io.github.freecode100year.vivant; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.os.IBinder; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import net.freehaven.tor.control.RawEventListener; +import net.freehaven.tor.control.TorControlConnection; + +import org.torproject.jni.TorService; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import IPtProxy.Controller; +import IPtProxy.IPtProxy; +import IPtProxy.OnTransportEvents; + +/** + * Publishes the chat room as a Tor onion service from the phone itself. + * Nobody learns anybody's IP address: visitors reach the phone through Tor, + * the phone reaches Tor through Tor, and the chat server only ever sees + * 127.0.0.1. Optional Snowflake or custom (obfs4 / webtunnel / …) bridges get + * the phone onto Tor from censored networks. + * + * Tor runs in this app's process (tor-android, the build Orbot uses); the + * bridges come from IPtProxy (lyrebird + snowflake), also as used by Orbot. + */ +final class TorOnion { + static final String BRIDGES_NONE = "none", BRIDGES_SNOWFLAKE = "snowflake", BRIDGES_CUSTOM = "custom"; + /** Virtual port of the onion service: visitors type plain `ssh guest@x.onion`. */ + static final int ONION_PORT = 22; + + private static final Pattern BOOTSTRAP = Pattern.compile("Bootstrapped (\\d+)%"); + + private final Context ctx; + private ServiceConnection conn; + private BroadcastReceiver receiver; + private Controller pt; + private final Set transports = new LinkedHashSet<>(); + private volatile boolean stopped; + + TorOnion(Context ctx) { this.ctx = ctx.getApplicationContext(); } + + static File serviceDir(Context c) { return new File(c.getFilesDir(), "onion"); } + + /** The .onion address, once Tor has created the service keys (kept across restarts). */ + static String address(Context c) { + try { + String h = new String(Files.readAllBytes(new File(serviceDir(c), "hostname").toPath()), StandardCharsets.US_ASCII).trim(); + return h.endsWith(".onion") ? h : null; + } catch (Exception e) { + return null; + } + } + + void start(int localPort) throws Exception { + stopped = false; + ServerState.tunnel = ServerState.TUNNEL_CONNECTING; + ServerState.torProgress = 0; + + // tor refuses a service directory others can read + File hs = serviceDir(ctx); + //noinspection ResultOfMethodCallIgnored + hs.mkdirs(); + hs.setReadable(false, false); + hs.setWritable(false, false); + hs.setExecutable(false, false); + hs.setReadable(true, true); + hs.setWritable(true, true); + hs.setExecutable(true, true); + + List rc = new ArrayList<>(Arrays.asList( + // no SOCKS / HTTP proxy: other apps on the phone can't use (or probe) this Tor + "SOCKSPort 0", + "HTTPTunnelPort 0", + "HiddenServiceDir " + hs.getAbsolutePath(), + "HiddenServicePort " + ONION_PORT + " 127.0.0.1:" + localPort, + "HiddenServiceMaxStreams 600", + "HiddenServiceMaxStreamsCloseCircuit 1", + // scrub addresses from anything tor logs, and log to stdout + // (nothing on Android) instead of the system log (logcat) + "SafeLogging 1", + "Log notice stdout")); + rc.addAll(bridgeConfig()); + Files.write(TorService.getTorrc(ctx).toPath(), (String.join("\n", rc) + "\n").getBytes(StandardCharsets.UTF_8)); + + receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent i) { + if (TorService.ACTION_ERROR.equals(i.getAction())) { + String t = i.getStringExtra(Intent.EXTRA_TEXT); + ServerState.log(stamp() + " [tor] ERROR " + t); + ServerState.tunnel = ServerState.TUNNEL_FAILED; + ServerState.tunnelError = "Tor: " + t; + } else if (TorService.STATUS_ON.equals(i.getStringExtra(TorService.EXTRA_STATUS))) { + // connected to Tor; the service is reachable once its descriptor is uploaded + if (ServerState.torProgress < 100) ServerState.torProgress = 100; + } + } + }; + IntentFilter f = new IntentFilter(TorService.ACTION_STATUS); + f.addAction(TorService.ACTION_ERROR); + LocalBroadcastManager.getInstance(ctx).registerReceiver(receiver, f); + + conn = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + TorService tor = ((TorService.LocalBinder) binder).getService(); + Thread t = new Thread(() -> listen(tor), "tor-events"); + t.setDaemon(true); + t.start(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (!stopped) { + ServerState.tunnel = ServerState.TUNNEL_FAILED; + ServerState.tunnelError = "Tor 意外停止 · Tor stopped unexpectedly"; + } + } + }; + if (!ctx.bindService(new Intent(ctx, TorService.class), conn, Context.BIND_AUTO_CREATE)) { + throw new IllegalStateException("bindService(TorService) failed"); + } + ServerState.log(stamp() + " [tor] starting" + (transports.isEmpty() ? "" : " via " + String.join(", ", transports))); + } + + /** Follows Tor's log and onion-service events through the control connection. */ + private void listen(TorService tor) { + TorControlConnection c = null; + for (int i = 0; i < 240 && !stopped && (c = tor.getTorControlConnection()) == null; i++) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + return; + } + } + if (c == null || stopped) return; + try { + c.addRawEventListener(new RawEventListener() { + @Override + public void onEvent(String keyword, String data) { + onTorEvent(keyword, data); + } + }); + // keep STATUS_CLIENT: TorService needs it to report "ON" + c.setEvents(Arrays.asList("STATUS_CLIENT", "NOTICE", "WARN", "ERR", "HS_DESC")); + } catch (Exception e) { + ServerState.log(stamp() + " [tor] control: " + e.getMessage()); + } + } + + private void onTorEvent(String keyword, String data) { + if (data == null) data = ""; + if ("HS_DESC".equals(keyword)) { + // "UPLOADED
..." — the onion service is now reachable + if (data.startsWith("UPLOADED ")) { + if (ServerState.tunnel != ServerState.TUNNEL_UP) ServerState.log(stamp() + " [tor] onion service published"); + ServerState.tunnel = ServerState.TUNNEL_UP; + ServerState.tunnelError = null; + } + return; + } + if ("STATUS_CLIENT".equals(keyword)) return; + Matcher m = BOOTSTRAP.matcher(data); + if (m.find()) ServerState.torProgress = Integer.parseInt(m.group(1)); + String msg = data.length() > 180 ? data.substring(0, 180) + "…" : data; + ServerState.log(stamp() + " [tor] " + keyword + " " + msg); + } + + /** UseBridges / ClientTransportPlugin / Bridge lines; starts the needed pluggable transports. */ + private List bridgeConfig() throws Exception { + List lines = bridgeLines(); + List rc = new ArrayList<>(); + if (lines.isEmpty()) return rc; + transports.clear(); + for (String l : lines) { + String t = transportOf(l); + if (t != null) transports.add(t); + } + if (!transports.isEmpty()) { + File state = new File(ctx.getFilesDir(), "pt_state"); + //noinspection ResultOfMethodCallIgnored + state.mkdirs(); + pt = new Controller(state.getAbsolutePath(), true, false, "WARN", new OnTransportEvents() { + @Override + public void connected(String name) {} + + @Override + public void error(String name, Exception e) { + if (e != null) ServerState.log(stamp() + " [" + name + "] " + e.getMessage()); + } + + @Override + public void stopped(String name, Exception e) {} + }); + for (String t : transports) { + if (IPtProxy.Snowflake.equals(t)) configureSnowflake(lines); + pt.start(t, ""); + rc.add("ClientTransportPlugin " + t + " socks5 127.0.0.1:" + pt.port(t)); + } + } + rc.add("UseBridges 1"); + for (String l : lines) rc.add("Bridge " + l); + return rc; + } + + /** The snowflake client takes broker, fronts and ICE servers from the controller, not the bridge line. */ + private void configureSnowflake(List lines) { + for (String l : lines) { + if (!IPtProxy.Snowflake.equals(transportOf(l))) continue; + for (String part : l.split("\\s+")) { + int eq = part.indexOf('='); + if (eq < 0) continue; + String k = part.substring(0, eq), v = part.substring(eq + 1); + if (k.equals("url")) pt.setSnowflakeBrokerUrl(v); + else if (k.equals("fronts") || k.equals("front")) pt.setSnowflakeFrontDomains(v); + else if (k.equals("ice")) pt.setSnowflakeIceServers(v); + } + return; + } + } + + private List bridgeLines() { + String mode = Prefs.torBridges(ctx); + List out = new ArrayList<>(); + if (BRIDGES_SNOWFLAKE.equals(mode)) { + try (BufferedReader r = new BufferedReader(new InputStreamReader(ctx.getAssets().open("snowflake-bridges.txt"), StandardCharsets.UTF_8))) { + String l; + while ((l = r.readLine()) != null) { + l = l.trim(); + if (!l.isEmpty() && !l.startsWith("#")) out.add(l); + } + } catch (Exception e) { + ServerState.log(stamp() + " [tor] no built-in bridges: " + e.getMessage()); + } + } else if (BRIDGES_CUSTOM.equals(mode)) { + out.addAll(parseBridges(Prefs.torCustomBridges(ctx))); + } + return out; + } + + /** One bridge per line, as given by bridges.torproject.org or Tor Browser ("Bridge " prefix optional). */ + static List parseBridges(String text) { + List out = new ArrayList<>(); + for (String l : text.split("\\r?\\n")) { + l = l.trim(); + if (l.regionMatches(true, 0, "Bridge ", 0, 7)) l = l.substring(7).trim(); + if (!l.isEmpty() && !l.startsWith("#")) out.add(l); + } + return out; + } + + /** The pluggable transport a bridge line needs, or null for a plain (vanilla) bridge. */ + static String transportOf(String line) { + String first = line.split("\\s+")[0]; + switch (first) { + case "obfs4": return IPtProxy.Obfs4; + case "webtunnel": return IPtProxy.Webtunnel; + case "snowflake": return IPtProxy.Snowflake; + case "meek_lite": case "meek": return IPtProxy.MeekLite; + default: return null; + } + } + + void stop() { + stopped = true; + if (receiver != null) { + LocalBroadcastManager.getInstance(ctx).unregisterReceiver(receiver); + receiver = null; + } + if (conn != null) { + try { + ctx.unbindService(conn); // unbinding stops tor + } catch (IllegalArgumentException ignored) { + } + conn = null; + } + if (pt != null) { + for (String t : transports) { + try { + pt.stop(t); + } catch (Exception ignored) { + } + } + pt = null; + } + transports.clear(); + } + + private static String stamp() { + return new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.ROOT).format(new java.util.Date()); + } +} diff --git a/android/app/src/main/java/io/github/freecode100year/vivant/MainActivity.java b/android/app/src/main/java/io/github/freecode100year/vivant/MainActivity.java index 586b33e..da48380 100644 --- a/android/app/src/main/java/io/github/freecode100year/vivant/MainActivity.java +++ b/android/app/src/main/java/io/github/freecode100year/vivant/MainActivity.java @@ -44,11 +44,13 @@ public class MainActivity extends Activity { private final Handler ui = new Handler(Looper.getMainLooper()); private TextView status, online, tunnelStatus, error, connect, netHint, netConnect, fingerprint, log, advToggle, logToggle; - private Button toggle, battery, etNew; + private Button toggle, battery, torNew, etNew; private EditText port, maxClients, rooms, extra, etPeer, etName, etSecret, frpServer, frpServerPort, frpToken, frpRemotePort; - private CheckBox autostart; - private RadioGroup tunnel; - private View etGroup, frpGroup, advGroup; + private CheckBox autostart, allowLan; + private RadioGroup tunnel, torBridges; + private EditText torCustom; + private TextView lanHint; + private View torGroup, etGroup, frpGroup, advGroup; private ScrollView logScroll; private long shownLog = -1; private int tick; @@ -80,21 +82,27 @@ protected void onCreate(Bundle b) { logToggle = findViewById(R.id.log_toggle); toggle = findViewById(R.id.toggle); battery = findViewById(R.id.battery); - etNew = findViewById(R.id.et_new); port = findViewById(R.id.port); maxClients = findViewById(R.id.max_clients); rooms = findViewById(R.id.rooms); extra = findViewById(R.id.extra); + etNew = findViewById(R.id.et_new); etPeer = findViewById(R.id.et_peer); etName = findViewById(R.id.et_name); etSecret = findViewById(R.id.et_secret); + etGroup = findViewById(R.id.et_group); frpServer = findViewById(R.id.frp_server); frpServerPort = findViewById(R.id.frp_server_port); frpToken = findViewById(R.id.frp_token); frpRemotePort = findViewById(R.id.frp_remote_port); autostart = findViewById(R.id.autostart); tunnel = findViewById(R.id.tunnel); - etGroup = findViewById(R.id.et_group); + torGroup = findViewById(R.id.tor_group); + torBridges = findViewById(R.id.tor_bridges); + torCustom = findViewById(R.id.tor_custom_bridges); + allowLan = findViewById(R.id.allow_lan); + torNew = findViewById(R.id.tor_new); + lanHint = findViewById(R.id.lan_hint); frpGroup = findViewById(R.id.frp_group); try { ((TextView) findViewById(R.id.version)).setText("v" + getPackageManager().getPackageInfo(getPackageName(), 0).versionName); @@ -102,6 +110,12 @@ protected void onCreate(Bundle b) { } loadFields(); + if (Prefs.easytierEdition(this)) { + // only EasyTier here: no mode to choose + tunnel.check(R.id.tunnel_et); + tunnel.setVisibility(View.GONE); + showTunnelGroups(); + } tunnel.setOnCheckedChangeListener((g, id) -> { Prefs.sp(this).edit().putString("tunnel", tunnelFromRadio()).apply(); showTunnelGroups(); @@ -109,9 +123,9 @@ protected void onCreate(Bundle b) { render(); }); autostart.setOnCheckedChangeListener((v, on) -> Prefs.sp(this).edit().putBoolean("autostart", on).apply()); - toggle.setOnClickListener(v -> { - if (ServerState.running || Prefs.running(this)) stopServer(); - else startServer(); + torBridges.setOnCheckedChangeListener((g, id) -> { + Prefs.sp(this).edit().putString("tor_bridges", bridgesFromRadio()).apply(); + showTunnelGroups(); }); etNew.setOnClickListener(v -> { Prefs.regenerateEasyTier(this); @@ -119,9 +133,23 @@ protected void onCreate(Bundle b) { etSecret.setText(Prefs.etSecret(this)); tick = 0; }); + torNew.setOnClickListener(v -> { + deleteTree(TorOnion.serviceDir(this)); + Toast.makeText(this, R.string.tor_new_done, Toast.LENGTH_LONG).show(); + tick = 0; + render(); + }); + allowLan.setOnCheckedChangeListener((v, on) -> { + Prefs.sp(this).edit().putBoolean("allow_lan", on).apply(); + tick = 0; + }); + toggle.setOnClickListener(v -> { + if (ServerState.running || Prefs.running(this)) stopServer(); + else startServer(); + }); findViewById(R.id.copy).setOnClickListener(v -> { - String text = connect.getText().toString(); - if (netConnect.getVisibility() == View.VISIBLE) text += "\n\n" + netConnect.getText(); + String text = connect.getVisibility() == View.VISIBLE ? connect.getText().toString() : ""; + if (netConnect.getVisibility() == View.VISIBLE) text += (text.isEmpty() ? "" : "\n\n") + netConnect.getText(); getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText("ssh", text)); Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show(); }); @@ -151,28 +179,44 @@ private void loadFields() { maxClients.setText(String.valueOf(Prefs.maxClients(this))); rooms.setText(Prefs.rooms(this)); extra.setText(Prefs.extraArgs(this)); - etPeer.setText(Prefs.sp(this).getString("et_peer", "")); autostart.setChecked(Prefs.autostart(this)); + etPeer.setText(Prefs.etPeer(this)); etName.setText(Prefs.etName(this)); etSecret.setText(Prefs.etSecret(this)); frpServer.setText(Prefs.frpServer(this)); frpServerPort.setText(String.valueOf(Prefs.frpServerPort(this))); frpToken.setText(Prefs.frpToken(this)); frpRemotePort.setText(String.valueOf(Prefs.frpRemotePort(this))); + torCustom.setText(Prefs.torCustomBridges(this)); + allowLan.setChecked(Prefs.allowLan(this)); + String bm = Prefs.torBridges(this); + torBridges.check(TorOnion.BRIDGES_SNOWFLAKE.equals(bm) ? R.id.bridges_snowflake : TorOnion.BRIDGES_CUSTOM.equals(bm) ? R.id.bridges_custom : R.id.bridges_none); String t = Prefs.tunnel(this); - tunnel.check(Prefs.TUNNEL_EASYTIER.equals(t) ? R.id.tunnel_et : Prefs.TUNNEL_FRP.equals(t) ? R.id.tunnel_frp : R.id.tunnel_off); + tunnel.check(Prefs.TUNNEL_TOR.equals(t) ? R.id.tunnel_tor : Prefs.TUNNEL_EASYTIER.equals(t) ? R.id.tunnel_et + : Prefs.TUNNEL_FRP.equals(t) ? R.id.tunnel_frp : R.id.tunnel_off); showTunnelGroups(); } private String tunnelFromRadio() { int id = tunnel.getCheckedRadioButtonId(); + if (id == R.id.tunnel_tor) return Prefs.TUNNEL_TOR; if (id == R.id.tunnel_et) return Prefs.TUNNEL_EASYTIER; if (id == R.id.tunnel_frp) return Prefs.TUNNEL_FRP; return Prefs.TUNNEL_OFF; } + private String bridgesFromRadio() { + int id = torBridges.getCheckedRadioButtonId(); + if (id == R.id.bridges_snowflake) return TorOnion.BRIDGES_SNOWFLAKE; + if (id == R.id.bridges_custom) return TorOnion.BRIDGES_CUSTOM; + return TorOnion.BRIDGES_NONE; + } + private void showTunnelGroups() { String t = tunnelFromRadio(); + torGroup.setVisibility(Prefs.TUNNEL_TOR.equals(t) ? View.VISIBLE : View.GONE); + torCustom.setVisibility(TorOnion.BRIDGES_CUSTOM.equals(bridgesFromRadio()) ? View.VISIBLE : View.GONE); + allowLan.setVisibility(Prefs.TUNNEL_OFF.equals(t) ? View.GONE : View.VISIBLE); etGroup.setVisibility(Prefs.TUNNEL_EASYTIER.equals(t) ? View.VISIBLE : View.GONE); frpGroup.setVisibility(Prefs.TUNNEL_FRP.equals(t) ? View.VISIBLE : View.GONE); } @@ -221,15 +265,18 @@ private boolean saveSettings() { } if (!ok && advGroup.getVisibility() != View.VISIBLE) fold(advGroup, advToggle, R.string.adv_title, R.string.adv_title_open); String t = tunnelFromRadio(); + String bridges = torCustom.getText().toString().trim(); + if (Prefs.TUNNEL_TOR.equals(t) && TorOnion.BRIDGES_CUSTOM.equals(bridgesFromRadio()) && TorOnion.parseBridges(bridges).isEmpty()) { + torCustom.setError(getString(R.string.err_need)); + ok = false; + } String name = etName.getText().toString().trim(), secret = etSecret.getText().toString().trim(); if (Prefs.TUNNEL_EASYTIER.equals(t)) { - if (name.isEmpty()) { - etName.setError(getString(R.string.err_need)); - ok = false; - } - if (secret.isEmpty()) { - etSecret.setError(getString(R.string.err_need)); - ok = false; + for (EditText e : new EditText[]{etPeer, etName, etSecret}) { + if (e.getText().toString().trim().isEmpty()) { + e.setError(getString(R.string.err_need)); + ok = false; + } } } int fsp = parse(frpServerPort), frp = parse(frpRemotePort); @@ -254,9 +301,12 @@ private boolean saveSettings() { .putInt("max_clients", m) .putString("rooms", rooms.getText().toString().trim()) .putString("extra_args", extra.getText().toString().trim()) - .putString("et_peer", etPeer.getText().toString().trim()) .putBoolean("autostart", autostart.isChecked()) .putString("tunnel", t) + .putString("tor_bridges", bridgesFromRadio()) + .putString("tor_custom_bridges", bridges) + .putBoolean("allow_lan", allowLan.isChecked()) + .putString("et_peer", etPeer.getText().toString().trim()) .putString("et_name", name) .putString("et_secret", secret) .putString("frp_server", server) @@ -267,6 +317,13 @@ private boolean saveSettings() { return true; } + private static void deleteTree(File f) { + File[] kids = f.listFiles(); + if (kids != null) for (File k : kids) deleteTree(k); + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + private static int parse(EditText e) { try { return Integer.parseInt(e.getText().toString().trim()); @@ -293,9 +350,14 @@ private void render() { online.setVisibility(run ? View.VISIBLE : View.GONE); int ts = ServerState.tunnel; + boolean torMode = Prefs.TUNNEL_TOR.equals(Prefs.tunnel(this)); tunnelStatus.setVisibility(ts == ServerState.TUNNEL_OFF ? View.GONE : View.VISIBLE); - if (ts == ServerState.TUNNEL_UP) { - tunnelStatus.setText(R.string.tunnel_up); + if (torMode && ts == ServerState.TUNNEL_CONNECTING) { + int pct = ServerState.torProgress; + tunnelStatus.setText(pct >= 100 ? getString(R.string.tunnel_tor_publishing) : getString(R.string.tunnel_tor_progress, pct)); + tunnelStatus.setTextColor(getColor(R.color.warn)); + } else if (ts == ServerState.TUNNEL_UP) { + tunnelStatus.setText(torMode ? R.string.tunnel_tor_up : R.string.tunnel_up); tunnelStatus.setTextColor(getColor(R.color.ok)); } else if (ts == ServerState.TUNNEL_FAILED) { tunnelStatus.setText(R.string.tunnel_failed); @@ -310,12 +372,16 @@ private void render() { boolean editable = !run && !wanted; for (View v : new View[]{port, maxClients, rooms, extra, etPeer, etName, etSecret, etNew, - frpServer, frpServerPort, frpToken, frpRemotePort, - findViewById(R.id.tunnel_off), findViewById(R.id.tunnel_et), findViewById(R.id.tunnel_frp)}) { + frpServer, frpServerPort, frpToken, frpRemotePort, torCustom, allowLan, torNew, + findViewById(R.id.bridges_none), findViewById(R.id.bridges_snowflake), findViewById(R.id.bridges_custom), + findViewById(R.id.tunnel_off), findViewById(R.id.tunnel_tor), findViewById(R.id.tunnel_et), findViewById(R.id.tunnel_frp)}) { v.setEnabled(editable); } if (tick++ % 5 == 0) { + boolean lanClosed = !Prefs.TUNNEL_OFF.equals(tunnelFromRadio()) && !allowLan.isChecked(); + lanHint.setText(lanClosed ? R.string.lan_closed : R.string.connect_lan); + connect.setVisibility(lanClosed ? View.GONE : View.VISIBLE); connect.setText(lanText(Prefs.port(this))); renderInternet(); fingerprint.setText(hostFingerprint()); @@ -336,19 +402,24 @@ private void render() { /** What people outside the LAN type, for the chosen kind of internet access. */ private void renderInternet() { String t = tunnelFromRadio(); - int p = parse(port); - int relay = p >= 1024 ? p + 2 : Prefs.relayPort(this); String cmd = null, hint = null; - if (Prefs.TUNNEL_EASYTIER.equals(t)) { + if (Prefs.TUNNEL_TOR.equals(t)) { + String onion = TorOnion.address(this); + if (onion == null) { + hint = getString(R.string.net_hint_tor_wait); + } else { + hint = getString(R.string.net_hint_tor); + cmd = "ssh -o ProxyCommand='nc -x 127.0.0.1:9150 %h %p' guest@" + onion + + "\n\ntorsocks ssh guest@" + onion; + } + } else if (Prefs.TUNNEL_EASYTIER.equals(t)) { String peer = etPeer.getText().toString().trim(); - if (peer.isEmpty()) peer = Prefs.DEFAULT_ET_PEER; - peer = peer.split("[,\\s]+")[0]; - cmd = "easytier-core --no-tun -d --network-name " + etName.getText().toString().trim() - + " --network-secret " + etSecret.getText().toString().trim() - + " -p " + peer - + " --port-forward tcp://127.0.0.1:2222/" + Prefs.ET_IP + ":" + relay + peer = peer.isEmpty() ? "tcp://…:11010" : peer.split("[,\\s]+")[0]; + int p = parse(port); + int relay = p >= 1024 ? p + 2 : Prefs.relayPort(this); + cmd = Tunnels.easytierVisitorCommand(etName.getText().toString().trim(), etSecret.getText().toString().trim(), peer, relay) + "\n\nssh -p 2222 guest@127.0.0.1"; - hint = getString(R.string.net_hint_et) + "\n" + getString(R.string.net_hint_et_app, relay, Prefs.ET_IP); + hint = getString(R.string.net_hint_et); } else if (Prefs.TUNNEL_FRP.equals(t)) { String server = frpServer.getText().toString().trim(); int remote = parse(frpRemotePort); @@ -357,12 +428,10 @@ private void renderInternet() { hint = getString(R.string.net_hint_frp); } } - netHint.setVisibility(cmd == null ? View.GONE : View.VISIBLE); + netHint.setVisibility(hint == null ? View.GONE : View.VISIBLE); netConnect.setVisibility(cmd == null ? View.GONE : View.VISIBLE); - if (cmd != null) { - netHint.setText(hint); - netConnect.setText(cmd); - } + if (hint != null) netHint.setText(hint); + if (cmd != null) netConnect.setText(cmd); } private String lanText(int p) { @@ -398,7 +467,6 @@ private List localAddresses() { for (InetAddress a : all) { if (a.isLoopbackAddress() || a.isLinkLocalAddress() || a.isAnyLocalAddress()) continue; String h = a.getHostAddress(); - if (Prefs.ET_IP.equals(h)) continue; if (a instanceof Inet4Address) { v4.add(h); } else if (a instanceof Inet6Address) { diff --git a/android/app/src/main/java/io/github/freecode100year/vivant/Prefs.java b/android/app/src/main/java/io/github/freecode100year/vivant/Prefs.java index 63c967d..58d3da3 100644 --- a/android/app/src/main/java/io/github/freecode100year/vivant/Prefs.java +++ b/android/app/src/main/java/io/github/freecode100year/vivant/Prefs.java @@ -10,14 +10,15 @@ final class Prefs { static final int DEFAULT_PORT = 2222; static final int DEFAULT_MAX_CLIENTS = 100; - static final String DEFAULT_ET_PEER = "tcp://public.easytier.top:11010"; static final String TUNNEL_OFF = "off"; - static final String TUNNEL_EASYTIER = "easytier"; static final String TUNNEL_FRP = "frp"; + static final String TUNNEL_TOR = "tor"; + static final String TUNNEL_EASYTIER = "easytier"; /** This phone's address inside the EasyTier network. */ static final String ET_IP = "10.144.144.1"; + static final String ET_IP_PREFIX = "10.144.144."; private Prefs() {} @@ -31,6 +32,8 @@ static SharedPreferences sp(Context c) { static int healthPort(Context c) { return port(c) + 1; } /** Local-only listener the tunnels forward to; everyone through it shares generous limits. */ static int relayPort(Context c) { return port(c) + 2; } + /** Local-only listener the onion service forwards to. */ + static int onionLocalPort(Context c) { return port(c) + 3; } static int maxClients(Context c) { return sp(c).getInt("max_clients", DEFAULT_MAX_CLIENTS); } static String rooms(Context c) { return sp(c).getString("rooms", ""); } static String extraArgs(Context c) { return sp(c).getString("extra_args", ""); } @@ -40,13 +43,18 @@ static SharedPreferences sp(Context c) { static void setRunning(Context c, boolean on) { sp(c).edit().putBoolean("running", on).apply(); } // internet access - static String tunnel(Context c) { return sp(c).getString("tunnel", TUNNEL_OFF); } + /** The EasyTier-only edition (no Tor, no FRP inside). */ + static boolean easytierEdition(Context c) { return c.getResources().getBoolean(R.bool.edition_easytier); } + + static String tunnel(Context c) { + if (easytierEdition(c)) return TUNNEL_EASYTIER; + String t = sp(c).getString("tunnel", TUNNEL_OFF); + return TUNNEL_TOR.equals(t) || TUNNEL_FRP.equals(t) || TUNNEL_EASYTIER.equals(t) ? t : TUNNEL_OFF; + } static String etName(Context c) { return generated(c, "et_name", "vivant-", 10); } static String etSecret(Context c) { return generated(c, "et_secret", "", 16); } - static String etPeer(Context c) { - String p = sp(c).getString("et_peer", "").trim(); - return p.isEmpty() ? DEFAULT_ET_PEER : p; - } + /** Relay node(s): the user's own. No public default — whoever runs the relay sees connections. */ + static String etPeer(Context c) { return sp(c).getString("et_peer", "").trim(); } static String etMachineId(Context c) { String id = sp(c).getString("et_machine_id", null); if (id == null) { @@ -55,17 +63,25 @@ static String etMachineId(Context c) { } return id; } + /** A new random EasyTier network name and password (the old ones stop working). */ + static void regenerateEasyTier(Context c) { + sp(c).edit().putString("et_name", "vivant-" + random(10)).putString("et_secret", random(16)).apply(); + } + static String torBridges(Context c) { return sp(c).getString("tor_bridges", TorOnion.BRIDGES_NONE); } + static String torCustomBridges(Context c) { return sp(c).getString("tor_custom_bridges", ""); } + /** + * With internet access on, the chat server listens on 127.0.0.1 only unless + * the user also wants direct LAN / hotspot connections (which reveal local + * addresses to the phone and to each other). + */ + static boolean allowLan(Context c) { return sp(c).getBoolean("allow_lan", false); } + static boolean lanOpen(Context c) { return TUNNEL_OFF.equals(tunnel(c)) || allowLan(c); } static String frpServer(Context c) { return sp(c).getString("frp_server", "").trim(); } static int frpServerPort(Context c) { return sp(c).getInt("frp_server_port", 7000); } static String frpToken(Context c) { return sp(c).getString("frp_token", ""); } static int frpRemotePort(Context c) { return sp(c).getInt("frp_remote_port", DEFAULT_PORT); } static String frpProxyName(Context c) { return generated(c, "frp_name", "vivant-", 6); } - /** A new random EasyTier network name and password. */ - static void regenerateEasyTier(Context c) { - sp(c).edit().putString("et_name", "vivant-" + random(10)).putString("et_secret", random(16)).apply(); - } - private static String generated(Context c, String key, String prefix, int n) { String v = sp(c).getString(key, null); if (v == null || v.isEmpty()) { diff --git a/android/app/src/main/java/io/github/freecode100year/vivant/ServerState.java b/android/app/src/main/java/io/github/freecode100year/vivant/ServerState.java index b9b293e..55be76c 100644 --- a/android/app/src/main/java/io/github/freecode100year/vivant/ServerState.java +++ b/android/app/src/main/java/io/github/freecode100year/vivant/ServerState.java @@ -18,6 +18,8 @@ final class ServerState { static volatile String error; static volatile int tunnel = TUNNEL_OFF; static volatile String tunnelError; + /** Tor bootstrap percentage. */ + static volatile int torProgress; private static long version; private ServerState() {} diff --git a/android/app/src/main/java/io/github/freecode100year/vivant/Tunnels.java b/android/app/src/main/java/io/github/freecode100year/vivant/Tunnels.java index 08f765f..d1157e3 100644 --- a/android/app/src/main/java/io/github/freecode100year/vivant/Tunnels.java +++ b/android/app/src/main/java/io/github/freecode100year/vivant/Tunnels.java @@ -15,32 +15,42 @@ import java.util.regex.Pattern; /** - * Internet access for the phone's chat room through a bundled tunnel: - * EasyTier (P2P mesh, no server of your own; visitors run EasyTier too) or - * frpc (your own frps server; visitors just ssh to it). Both forward to the - * server's local relay listener, so tunnelled users share generous limits - * instead of all counting as one address (127.0.0.1). + * Internet access through a bundled tunnel client other than Tor. Both + * forward to the chat server's local relay listener, so every visitor reaches + * it as 127.0.0.1 and the server never learns a visitor's address. + * + * EasyTier: relay only, through the user's own relay node. The phone's build + * carries easytier-no-ip.patch (it never reports its addresses to other + * nodes), and P2P, hole punching, STUN, IPv6 and listeners are all off, so + * the phone and visitors never learn each other's IP. Visitors run stock + * EasyTier with the matching flags (--stun-servers with no value, P2P off). + * + * frpc: the user's own frps; frps sees visitors' IPs, frpc never does (no + * Proxy Protocol, ever). + * + * Nothing these clients print is kept with an address in it: every IP-looking + * string is masked before a line reaches the in-memory log. */ final class Tunnels { private Tunnels() {} - static String binary(Context c, String kind) { - String lib = Prefs.TUNNEL_EASYTIER.equals(kind) ? "libeasytier.so" : "libfrpc.so"; - return new File(c.getApplicationInfo().nativeLibraryDir, lib).getAbsolutePath(); - } - static List command(Context c, String kind) throws IOException { List cmd = new ArrayList<>(); - cmd.add(binary(c, kind)); if (Prefs.TUNNEL_EASYTIER.equals(kind)) { - // no TUN device (that would need Android's VPN permission): other - // nodes reach this phone's loopback ports at ET_IP through EasyTier's - // own network stack + cmd.add(new File(c.getApplicationInfo().nativeLibraryDir, "libeasytier.so").getAbsolutePath()); add(cmd, "--no-tun", "-i", Prefs.ET_IP, "--network-name", Prefs.etName(c), "--network-secret", Prefs.etSecret(c), "--hostname", "vivant", "--machine-id", Prefs.etMachineId(c), + // relay only: no listener, no P2P, no hole punching + "--no-listener", + "--disable-p2p", + "--disable-udp-hole-punching", + "--disable-tcp-hole-punching", + // no STUN (it would learn, and share, the public IP) and no IPv6 + "--stun-servers", + "--disable-ipv6", "--bind-device", "false", "--file-log-level", "off", "--console-log-level", "info"); @@ -48,6 +58,7 @@ static List command(Context c, String kind) throws IOException { if (!p.isEmpty()) add(cmd, "-p", p); } } else { + cmd.add(new File(c.getApplicationInfo().nativeLibraryDir, "libfrpc.so").getAbsolutePath()); File conf = new File(c.getFilesDir(), "frpc.toml"); Files.write(conf.toPath(), frpConfig(c).getBytes(StandardCharsets.UTF_8)); add(cmd, "-c", conf.getAbsolutePath()); @@ -55,6 +66,15 @@ static List command(Context c, String kind) throws IOException { return cmd; } + /** What a visitor runs (stock EasyTier) to reach this phone, before `ssh -p 2222 guest@127.0.0.1`. */ + static String easytierVisitorCommand(String name, String secret, String peer, int relayPort) { + return "easytier-core --no-tun -d --no-listener --disable-p2p --stun-servers" + + " --network-name " + name + + " --network-secret " + secret + + " -p " + peer + + " --port-forward tcp://127.0.0.1:2222/" + Prefs.ET_IP + ":" + relayPort; + } + static String frpConfig(Context c) { StringBuilder t = new StringBuilder(); t.append("serverAddr = ").append(q(Prefs.frpServer(c))).append('\n'); @@ -68,6 +88,7 @@ static String frpConfig(Context c) { t.append("type = \"tcp\"\nlocalIP = \"127.0.0.1\"\n"); t.append("localPort = ").append(Prefs.relayPort(c)).append('\n'); t.append("remotePort = ").append(Prefs.frpRemotePort(c)).append('\n'); + // deliberately no transport.proxyProtocolVersion: it would hand visitors' IPs to the phone return t.toString(); } @@ -86,12 +107,40 @@ private static void add(List l, String... a) { for (String s : a) l.add(s); } - // ---- output: shorten for the log view and track the connection state -- + // ---- output ------------------------------------------------------------ // 2026-11-08T08:34:14.514Z INFO CORE::INSTANCE::CONNECTION: [uuid] new peer added peer_id=1 private static final Pattern ET_LINE = Pattern.compile("^\\d{4}-\\d\\d-\\d\\dT\\S+\\s+(\\w+)\\s+\\S+?:\\s+(?:\\[[0-9a-f-]{36}\\]\\s+)?(.*)$"); // 2026-11-08 03:39:39.016 [I] [client/control.go:174] [816ebbb0ac97ff72] [vivant-ab] start proxy success private static final Pattern FRP_LINE = Pattern.compile("^\\S+ \\S+ \\[(\\w)\\] \\[[^\\]]+\\] (?:\\[[0-9a-f]+\\] )?(.*)$"); + private static final Pattern IPV4 = Pattern.compile("(?= 3 || t.contains("::")) && t.chars().anyMatch(Character::isDigit) && !t.equals("::1"); + out.append(s, last, m.start()).append(v6 ? "•:•:•" : t); + last = m.end(); + } + return out.append(s.substring(last)).toString(); + } /** Returns the line to show, or null to hide it (EasyTier's startup config dump). Updates ServerState. */ static String onLine(String kind, String raw) { @@ -124,8 +173,8 @@ static String onLine(String kind, String raw) { ServerState.tunnelError = "frp: 连不上服务器,正在重试 · can't reach the server, retrying"; } } + msg = maskAddresses(msg); if (msg.length() > 180) msg = msg.substring(0, 180) + "…"; - String time = new SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(new Date()); - return time + " [" + kind + "] " + level + " " + msg; + return new SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(new Date()) + " [" + kind + "] " + level + " " + msg; } } diff --git a/android/app/src/main/java/io/github/freecode100year/vivant/VivantService.java b/android/app/src/main/java/io/github/freecode100year/vivant/VivantService.java index 148c609..f1a74e2 100644 --- a/android/app/src/main/java/io/github/freecode100year/vivant/VivantService.java +++ b/android/app/src/main/java/io/github/freecode100year/vivant/VivantService.java @@ -43,6 +43,7 @@ public class VivantService extends Service { private final Handler main = new Handler(Looper.getMainLooper()); private final Child server = new Child(main); private final Child tunnel = new Child(main); + private TorOnion tor; private String tunnelKind = Prefs.TUNNEL_OFF; private boolean wanted; private int quickFails, tunnelFails; @@ -94,13 +95,17 @@ private void launchServer() { int port = Prefs.port(this); List cmd = new ArrayList<>(); cmd.add(new File(getApplicationInfo().nativeLibraryDir, "libvivant.so").getAbsolutePath()); - add(cmd, "-addr", ":" + port, + boolean onion = Prefs.TUNNEL_TOR.equals(Prefs.tunnel(this)); + // with internet access on, nothing but the phone itself reaches the + // server directly unless the user also opened the LAN entrance + add(cmd, "-addr", (Prefs.lanOpen(this) ? ":" : "127.0.0.1:") + port, "-host-key", new File(getFilesDir(), "host_ed25519").getAbsolutePath(), "-health-addr", "127.0.0.1:" + Prefs.healthPort(this), "-relay-addr", "127.0.0.1:" + Prefs.relayPort(this), "-max-clients", String.valueOf(Prefs.maxClients(this)), // a phone usually has no use for the Tor exit list and it costs mobile data "-tor-exits=false"); + if (onion) add(cmd, "-onion-addr", "127.0.0.1:" + Prefs.onionLocalPort(this)); String rooms = Prefs.rooms(this).trim(); if (!rooms.isEmpty()) add(cmd, "-rooms", rooms); cmd.addAll(splitArgs(Prefs.extraArgs(this))); @@ -144,6 +149,20 @@ private void launchTunnel() { if (Prefs.TUNNEL_OFF.equals(tunnelKind)) ServerState.tunnel = ServerState.TUNNEL_OFF; return; } + if (Prefs.TUNNEL_TOR.equals(tunnelKind)) { + if (tor != null) return; + tor = new TorOnion(this); + try { + tor.start(Prefs.onionLocalPort(this)); + } catch (Exception e) { + tor.stop(); + tor = null; + ServerState.tunnel = ServerState.TUNNEL_FAILED; + ServerState.tunnelError = getString(R.string.err_tunnel_start, e.getMessage()); + } + updateNotification(); + return; + } final String kind = tunnelKind; ServerState.tunnel = ServerState.TUNNEL_CONNECTING; try { @@ -211,6 +230,10 @@ private void stopAll() { if (poller != null) poller.interrupt(); poller = null; tunnel.stop(); + if (tor != null) { + tor.stop(); + tor = null; + } server.stop(); releaseLocks(); ServerState.running = false; diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 8fd3490..b411845 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -100,6 +100,7 @@ android:text="@string/connect_title" /> @@ -151,6 +152,11 @@ style="@style/Radio" android:text="@string/net_off" /> + + + + + + + + + + + + + + + + + + +