From f98a22d871ed04818627a3f2929822269299861d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 04:42:41 -0500 Subject: [PATCH 1/7] Android: Tor onion service so no IP is ever exposed Internet access now offers a Tor onion service (tor-android + IPtProxy, the same builds Orbot uses) as the recommended mode: visitors, the phone and the chat server never learn each other's IP. Snowflake or custom obfs4/webtunnel bridges for censored networks; no SOCKS port for other apps; LAN entrance closed by default in Tor mode; button to retire the onion address. EasyTier is now relay-only (no listener, no P2P, no hole punching) and has no public default relay; EasyTier and FRP are marked as visible to the relay / frps. Docs rewritten around Tor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCUwNR1jQiEZdMrFnKZUuP --- README.en.md | 2 +- README.md | 2 +- android/app/build.gradle | 8 + .../app/src/main/assets/snowflake-bridges.txt | 4 + .../freecode100year/vivant/MainActivity.java | 111 +++++-- .../github/freecode100year/vivant/Prefs.java | 14 +- .../freecode100year/vivant/ServerState.java | 2 + .../freecode100year/vivant/TorOnion.java | 307 ++++++++++++++++++ .../freecode100year/vivant/Tunnels.java | 13 +- .../freecode100year/vivant/VivantService.java | 24 +- .../app/src/main/res/layout/activity_main.xml | 88 ++++- android/app/src/main/res/values/strings.xml | 29 +- android/app/src/main/res/values/themes.xml | 9 + android/gradle.properties | 2 +- android/settings.gradle | 2 + docs/android.en.md | 61 +++- docs/android.md | 63 +++- 17 files changed, 660 insertions(+), 81 deletions(-) create mode 100644 android/app/src/main/assets/snowflake-bridges.txt create mode 100644 android/app/src/main/java/io/github/freecode100year/vivant/TorOnion.java diff --git a/README.en.md b/README.en.md index 1c5197b..9b1db17 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). diff --git a/README.md b/README.md index e1be976..6c0b6a0 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-迁移)。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 14f86ed..b57bc26 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -73,3 +73,11 @@ android { checkReleaseBuilds false } } + +dependencies { + // Tor itself (the same build Orbot uses) and its control-port library + implementation "info.guardianproject:tor-android:0.4.9.14" + implementation "info.guardianproject:jtorctl:0.4.5.7" + // Snowflake / obfs4 / webtunnel bridges for censored networks + implementation "com.netzarchitekten:IPtProxy:5.7.0" +} diff --git a/android/app/src/main/assets/snowflake-bridges.txt b/android/app/src/main/assets/snowflake-bridges.txt new file mode 100644 index 0000000..72ce887 --- /dev/null +++ b/android/app/src/main/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/main/java/io/github/freecode100year/vivant/MainActivity.java b/android/app/src/main/java/io/github/freecode100year/vivant/MainActivity.java index 586b33e..ee3227a 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, etNew, torNew; 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, torAllowLan; + 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; @@ -95,6 +97,12 @@ protected void onCreate(Bundle b) { 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); + torAllowLan = findViewById(R.id.tor_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); @@ -109,6 +117,20 @@ protected void onCreate(Bundle b) { render(); }); autostart.setOnCheckedChangeListener((v, on) -> Prefs.sp(this).edit().putBoolean("autostart", on).apply()); + torBridges.setOnCheckedChangeListener((g, id) -> { + Prefs.sp(this).edit().putString("tor_bridges", bridgesFromRadio()).apply(); + showTunnelGroups(); + }); + torNew.setOnClickListener(v -> { + deleteTree(TorOnion.serviceDir(this)); + Toast.makeText(this, R.string.tor_new_done, Toast.LENGTH_LONG).show(); + tick = 0; + render(); + }); + torAllowLan.setOnCheckedChangeListener((v, on) -> { + Prefs.sp(this).edit().putBoolean("tor_allow_lan", on).apply(); + tick = 0; + }); toggle.setOnClickListener(v -> { if (ServerState.running || Prefs.running(this)) stopServer(); else startServer(); @@ -120,8 +142,8 @@ protected void onCreate(Bundle b) { tick = 0; }); 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(); }); @@ -159,20 +181,35 @@ private void loadFields() { frpServerPort.setText(String.valueOf(Prefs.frpServerPort(this))); frpToken.setText(Prefs.frpToken(this)); frpRemotePort.setText(String.valueOf(Prefs.frpRemotePort(this))); + torCustom.setText(Prefs.torCustomBridges(this)); + torAllowLan.setChecked(Prefs.torAllowLan(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); etGroup.setVisibility(Prefs.TUNNEL_EASYTIER.equals(t) ? View.VISIBLE : View.GONE); frpGroup.setVisibility(Prefs.TUNNEL_FRP.equals(t) ? View.VISIBLE : View.GONE); } @@ -222,7 +259,16 @@ 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 name = etName.getText().toString().trim(), secret = etSecret.getText().toString().trim(); + 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; + } if (Prefs.TUNNEL_EASYTIER.equals(t)) { + if (etPeer.getText().toString().trim().isEmpty()) { + etPeer.setError(getString(R.string.err_need)); + ok = false; + } if (name.isEmpty()) { etName.setError(getString(R.string.err_need)); ok = false; @@ -257,6 +303,9 @@ private boolean saveSettings() { .putString("et_peer", etPeer.getText().toString().trim()) .putBoolean("autostart", autostart.isChecked()) .putString("tunnel", t) + .putString("tor_bridges", bridgesFromRadio()) + .putString("tor_custom_bridges", bridges) + .putBoolean("tor_allow_lan", torAllowLan.isChecked()) .putString("et_name", name) .putString("et_secret", secret) .putString("frp_server", server) @@ -267,6 +316,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 +349,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 +371,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, torAllowLan, 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_TOR.equals(tunnelFromRadio()) && !torAllowLan.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()); @@ -339,11 +404,19 @@ private void renderInternet() { 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() + peer = peer.isEmpty() ? "tcp://…:11010" : peer.split("[,\\s]+")[0]; + cmd = "easytier-core --no-tun -d --no-listener --disable-p2p --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 @@ -357,12 +430,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) { 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..e9515c4 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,11 +10,11 @@ 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"; /** This phone's address inside the EasyTier network. */ static final String ET_IP = "10.144.144.1"; @@ -31,6 +31,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", ""); } @@ -43,10 +45,12 @@ static SharedPreferences sp(Context c) { static String tunnel(Context c) { return sp(c).getString("tunnel", 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; there is deliberately no public default (it would see everyone's IP). */ + static String etPeer(Context c) { return sp(c).getString("et_peer", "").trim(); } + 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 Tor on: also accept direct LAN / hotspot connections (those reveal local addresses). */ + static boolean torAllowLan(Context c) { return sp(c).getBoolean("tor_allow_lan", false); } static String etMachineId(Context c) { String id = sp(c).getString("et_machine_id", null); if (id == null) { 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/TorOnion.java b/android/app/src/main/java/io/github/freecode100year/vivant/TorOnion.java new file mode 100644 index 0000000..1384c77 --- /dev/null +++ b/android/app/src/main/java/io/github/freecode100year/vivant/TorOnion.java @@ -0,0 +1,307 @@ +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")); + 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/Tunnels.java b/android/app/src/main/java/io/github/freecode100year/vivant/Tunnels.java index 08f765f..ab3f44d 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,9 +15,10 @@ 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 + * Internet access for the phone's chat room through a bundled tunnel other + * than Tor: EasyTier through the user's own relay node (visitors run + * EasyTier too) or frpc to the user's own frps (visitors just ssh to it). + * Unlike Tor, the relay / frps sees visitors' IP addresses. 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). */ @@ -36,11 +37,17 @@ static List command(Context c, String kind) throws IOException { // 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 + // relay only: no listener, no P2P and no hole punching, so the + // phone and visitors never connect to (and learn) each other's IP 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), + "--no-listener", + "--disable-p2p", + "--disable-udp-hole-punching", + "--disable-tcp-hole-punching", "--bind-device", "false", "--file-log-level", "off", "--console-log-level", "info"); 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..7938935 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,16 @@ 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)); + // Tor without LAN: nothing but the phone itself can reach the server directly + add(cmd, "-addr", (onion && !Prefs.torAllowLan(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 +148,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 +229,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..f09d3b0 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" /> + + + + + + + + + + + + + + + + + + +