summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorcyberta <cyberta@riseup.net>2026-03-09 20:46:13 +0100
committercyberta <cyberta@riseup.net>2026-03-16 11:08:57 +0000
commit31f29fcf0de6f7c254c1ca608aef1accce4238b1 (patch)
treea5c139b893a58f8e4a72389af6247ef77cace4fe /app
parentb6c66d85dedfb82c6bf337b3eb2aa75166d0be42 (diff)
Reimpement IPtProxy management code: IPtProxys 'new' controller can only be initiated once, se we need to switch to a singleton-like architecture to keep a global reference of it and pass it around. The update includes new support for sqs queues as signaling channel, which will be only provided to users in some regions. IPtProxy now has proper state callbacks, so the preliminary code that scraped logs to retrieve the snowflake state could be removed.
Diffstat (limited to 'app')
-rw-r--r--app/assets/fronts4
-rw-r--r--app/assets/fronts-amp4
-rw-r--r--app/assets/fronts-http3
-rw-r--r--app/assets/fronts-sqs3
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/base/BitmaskApp.java5
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/base/models/Constants.java2
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/base/utils/ClientTransportPluginProvider.java39
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/base/utils/PreferenceHelper.java10
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/tor/ClientTransportPlugin.java292
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/tor/SnowflakePlugin.java328
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/tor/TorServiceCommand.java3
-rw-r--r--app/src/main/java/se/leap/bitmaskclient/tor/TorStatusObservable.java84
-rw-r--r--app/src/main/res/values/strings.xml5
13 files changed, 410 insertions, 372 deletions
diff --git a/app/assets/fronts b/app/assets/fronts
deleted file mode 100644
index acd1cd94..00000000
--- a/app/assets/fronts
+++ /dev/null
@@ -1,4 +0,0 @@
-snowflake-target https://1098762253.rsc.cdn77.org
-snowflake-fronts www.cdn77.com,www.phpmyadmin.net
-snowflake-stun stun:stun.antisip.com:3478,stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443
-utls-imitate hellorandomizedalpn \ No newline at end of file
diff --git a/app/assets/fronts-amp b/app/assets/fronts-amp
new file mode 100644
index 00000000..94ec0212
--- /dev/null
+++ b/app/assets/fronts-amp
@@ -0,0 +1,4 @@
+url https://1098762253.rsc.cdn77.org
+fronts www.google.com
+ice stun:stun.antisip.com:3478,stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443
+ampcache https://cdn.ampproject.org/
diff --git a/app/assets/fronts-http b/app/assets/fronts-http
new file mode 100644
index 00000000..57fa7ff9
--- /dev/null
+++ b/app/assets/fronts-http
@@ -0,0 +1,3 @@
+url https://1098762253.rsc.cdn77.org
+fronts www.cdn77.com,www.phpmyadmin.net
+ice stun:stun.antisip.com:3478,stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443
diff --git a/app/assets/fronts-sqs b/app/assets/fronts-sqs
new file mode 100644
index 00000000..83f99a28
--- /dev/null
+++ b/app/assets/fronts-sqs
@@ -0,0 +1,3 @@
+ice stun:stun.antisip.com:3478,stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443
+sqsqueue https://sqs.us-east-2.amazonaws.com/490393006362/snowflake-broker
+sqscreds eyJhd3MtYWNjZXNzLWtleS1pZCI6IkFLSUFYRUxOVE9FTkdCWVU3NkVBIiwiYXdzLXNlY3JldC1rZXkiOiJHdTlTWm9xTVg2WENIa2dZazlHZnBvYlpoVFZMdzNpL0N5cFdVVGZOIn0=
diff --git a/app/src/main/java/se/leap/bitmaskclient/base/BitmaskApp.java b/app/src/main/java/se/leap/bitmaskclient/base/BitmaskApp.java
index 6b3ba348..cbe954db 100644
--- a/app/src/main/java/se/leap/bitmaskclient/base/BitmaskApp.java
+++ b/app/src/main/java/se/leap/bitmaskclient/base/BitmaskApp.java
@@ -28,6 +28,7 @@ import static se.leap.bitmaskclient.base.utils.PreferenceHelper.getSavedProvider
import android.content.IntentFilter;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
@@ -44,6 +45,7 @@ import java.security.Security;
import se.leap.bitmaskclient.BuildConfig;
import se.leap.bitmaskclient.appUpdate.DownloadBroadcastReceiver;
import se.leap.bitmaskclient.base.models.ProviderObservable;
+import se.leap.bitmaskclient.base.utils.ClientTransportPluginProvider;
import se.leap.bitmaskclient.base.utils.PRNGFixes;
import se.leap.bitmaskclient.base.utils.PreferenceHelper;
import se.leap.bitmaskclient.eip.EipSetupObserver;
@@ -61,12 +63,12 @@ public class BitmaskApp extends MultiDexApplication implements DefaultLifecycleO
private ProviderObservable providerObservable;
private DownloadBroadcastReceiver downloadBroadcastReceiver;
private TorStatusObservable torStatusObservable;
-
private ProviderSetupObservable providerSetupObservable;
private PreferenceHelper preferenceHelper;
+
@Override
public void onCreate() {
super.onCreate();
@@ -85,6 +87,7 @@ public class BitmaskApp extends MultiDexApplication implements DefaultLifecycleO
providerObservable.updateProvider(getSavedProviderFromSharedPreferences());
torStatusObservable = TorStatusObservable.getInstance();
providerSetupObservable = ProviderSetupObservable.getInstance();
+ ClientTransportPluginProvider.init(this);
EipSetupObserver.init(this);
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
diff --git a/app/src/main/java/se/leap/bitmaskclient/base/models/Constants.java b/app/src/main/java/se/leap/bitmaskclient/base/models/Constants.java
index b8849c4d..e01542f5 100644
--- a/app/src/main/java/se/leap/bitmaskclient/base/models/Constants.java
+++ b/app/src/main/java/se/leap/bitmaskclient/base/models/Constants.java
@@ -44,6 +44,8 @@ public interface Constants {
String PREFERRED_CITY = "preferred_city";
// ATTENTION: this key is also used in bitmask-core for persistence
String COUNTRYCODE = "COUNTRYCODE";
+ // ATTENTION: this key is also used in bitmask-core
+ String GEOIP_LOOKUP_URL = "";
String USE_SNOWFLAKE = "use_snowflake";
String PREFER_UDP = "prefer_UDP";
String GATEWAY_PINNING = "gateway_pinning";
diff --git a/app/src/main/java/se/leap/bitmaskclient/base/utils/ClientTransportPluginProvider.java b/app/src/main/java/se/leap/bitmaskclient/base/utils/ClientTransportPluginProvider.java
new file mode 100644
index 00000000..8e6387d4
--- /dev/null
+++ b/app/src/main/java/se/leap/bitmaskclient/base/utils/ClientTransportPluginProvider.java
@@ -0,0 +1,39 @@
+package se.leap.bitmaskclient.base.utils;
+
+import android.content.Context;
+
+import org.torproject.jni.ClientTransportPluginInterface;
+
+import se.leap.bitmaskclient.tor.SnowflakePlugin;
+
+/**
+ * ClientTransportPluginProvider keeps a global reference of an implementation of ClientTransportPluginInterface.
+ * Currently the underlying plugin is utilizing IPtPtroxy and that library cannot be instantiated twice.
+ * Therefore we need keep a global reference to it and pass that around.
+ */
+public class ClientTransportPluginProvider {
+ private final SnowflakePlugin plugin;
+ private static ClientTransportPluginProvider instance = null;
+
+ private ClientTransportPluginProvider(Context context) throws IllegalStateException {
+ plugin = new SnowflakePlugin(context);
+ }
+
+ public static void init(Context context) {
+ if (instance == null) {
+ try {
+ instance = new ClientTransportPluginProvider(context);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+
+ public static ClientTransportPluginInterface get() {
+ if (instance != null) {
+ return instance.plugin;
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/se/leap/bitmaskclient/base/utils/PreferenceHelper.java b/app/src/main/java/se/leap/bitmaskclient/base/utils/PreferenceHelper.java
index 200617cf..38e32861 100644
--- a/app/src/main/java/se/leap/bitmaskclient/base/utils/PreferenceHelper.java
+++ b/app/src/main/java/se/leap/bitmaskclient/base/utils/PreferenceHelper.java
@@ -20,6 +20,7 @@ import static se.leap.bitmaskclient.base.models.Constants.EIP_RESTART_ON_BOOT;
import static se.leap.bitmaskclient.base.models.Constants.EXCLUDED_APPS;
import static se.leap.bitmaskclient.base.models.Constants.FIRST_TIME_USER_DATE;
import static se.leap.bitmaskclient.base.models.Constants.GATEWAY_PINNING;
+import static se.leap.bitmaskclient.base.models.Constants.GEOIP_LOOKUP_URL;
import static se.leap.bitmaskclient.base.models.Constants.LAST_DONATION_REMINDER_DATE;
import static se.leap.bitmaskclient.base.models.Constants.LAST_UPDATE_CHECK;
import static se.leap.bitmaskclient.base.models.Constants.LAST_USED_PROFILE;
@@ -218,7 +219,7 @@ public class PreferenceHelper {
try {
introducer = Introducer.fromUrl(BitmaskCoreProvider.getBitmaskMobile().getIntroducerURLByDomain(domain));
} catch (Exception e) {
- e.printStackTrace();
+ Log.w(TAG, "introducer lookup: " + e.getMessage());
}
Provider customProvider = new Provider(mainURL);
customProvider.setIntroducer(introducer);
@@ -670,6 +671,13 @@ public class PreferenceHelper {
putString(COUNTRYCODE, countryCode);
}
+ public static void setGeoIPLookupURL(String url) {
+ putString(GEOIP_LOOKUP_URL, url);
+ }
+ public static String getGeoIPLookupURL() {
+ return getString(GEOIP_LOOKUP_URL, null);
+ }
+
public static String getPreferredCity() {
return useObfuscationPinning() ? null : getString(PREFERRED_CITY, null);
}
diff --git a/app/src/main/java/se/leap/bitmaskclient/tor/ClientTransportPlugin.java b/app/src/main/java/se/leap/bitmaskclient/tor/ClientTransportPlugin.java
deleted file mode 100644
index ca71a6e2..00000000
--- a/app/src/main/java/se/leap/bitmaskclient/tor/ClientTransportPlugin.java
+++ /dev/null
@@ -1,292 +0,0 @@
-package se.leap.bitmaskclient.tor;
-/**
- * Copyright (c) 2024 LEAP Encryption Access Project and contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.RETRY_AMP_CACHE_RENDEZVOUS;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.RETRY_HTTP_RENDEZVOUS;
-
-import android.content.Context;
-import android.os.FileObserver;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.torproject.jni.ClientTransportPluginInterface;
-
-import java.beans.PropertyChangeEvent;
-import java.beans.PropertyChangeListener;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.lang.ref.WeakReference;
-import java.net.URL;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Random;
-import java.util.Scanner;
-import java.util.Vector;
-import java.util.concurrent.TimeoutException;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import IPtProxy.Controller;
-import IPtProxy.IPtProxy;
-import IPtProxy.OnTransportEvents;
-
-public class ClientTransportPlugin implements ClientTransportPluginInterface, PropertyChangeListener {
- public static String TAG = ClientTransportPlugin.class.getSimpleName();
-
- private HashMap<String, String> mFronts;
- private final WeakReference<Context> contextRef;
- private long snowflakePort = -1;
- private FileObserver logFileObserver;
- private static final Pattern SNOWFLAKE_LOG_TIMESTAMP_PATTERN = Pattern.compile("((19|2[0-9])[0-9]{2}\\/\\d{1,2}\\/\\d{1,2} \\d{1,2}:\\d{1,2}:\\d{1,2}) ([\\S|\\s]+)");
- private TorStatusObservable.SnowflakeStatus snowflakeStatus;
- private String logfilePath;
- Handler handler;
- HandlerThread handlerThread;
- Controller controller;
-
- public ClientTransportPlugin(Context context) {
- this.contextRef = new WeakReference<>(context);
- handlerThread = new HandlerThread("clientTransportPlugin", Thread.MIN_PRIORITY);
- loadCdnFronts(context);
- controller = initializeController(context.getApplicationContext());
- }
-
- private Controller initializeController(Context context) {
- File ptDir = new File(context.getApplicationContext().getCacheDir(), "/pt_state");
- return IPtProxy.newController(ptDir.getAbsolutePath(), true, false, "DEBUG", new OnTransportEvents() {
- @Override
- public void connected(String s) { }
-
- @Override
- public void error(String s, Exception e) { }
-
- @Override
- public void stopped(String s, Exception e) { }
- });
- }
-
- @Override
- public void start() {
- Context context = contextRef.get();
- if (context == null) {
- return;
- }
- handlerThread.start();
- handler = new Handler(handlerThread.getLooper());
- TorStatusObservable.getInstance().addObserver(this);
- File logfile = new File(context.getApplicationContext().getCacheDir(), "snowflake.log");
- try {
- if (logfile.exists()) {
- logfile.delete();
- }
- logfile.createNewFile();
- } catch (IOException e) {
- e.printStackTrace();
- }
- this.logfilePath = logfile.getAbsolutePath();
- Random random = new Random();
- boolean useAmpCache = random.nextInt(2) == 0;
- startConnectionAttempt(useAmpCache, logfilePath);
- watchLogFile(logfile);
- }
-
- private void startConnectionAttempt(boolean useAmpCache, @NonNull String logfilePath) {
- //this is using the current, default Tor snowflake infrastructure
- String target = getCdnFront("snowflake-target");
- String fronts = getCdnFront("snowflake-fronts");
- String stunServer = getCdnFront("snowflake-stun");
- String ampCache = null;
- if (useAmpCache) {
- target = "https://snowflake-broker.torproject.net/";
- ampCache = "https://cdn.ampproject.org/";
- fronts = "www.google.com";
- }
-
- snowflakePort = startSnowflake(stunServer, target, fronts, ampCache, null, null, logfilePath, false, false, false, 5);
- Log.d(TAG, "startSnowflake running on port: " + snowflakePort);
- }
-
-/**
- StartSnowflake - Start IPtProxy's Snowflake client.
- @param ice Comma-separated list of ICE servers.
- @param url URL of signaling broker.
- @param fronts Comma-separated list of front domains.
- @param ampCache OPTIONAL. URL of AMP cache to use as a proxy for signaling.
- Only needed when you want to do the rendezvous over AMP instead of a domain fronted server.
- @param sqsQueueURL OPTIONAL. URL of SQS Queue to use as a proxy for signaling.
- @param sqsCredsStr OPTIONAL. Credentials to access SQS Queue
- @param logFile Name of log file. OPTIONAL. Defaults to no log.
- @param logToStateDir Resolve the log file relative to Tor's PT state dir.
- @param keepLocalAddresses Keep local LAN address ICE candidates.
- @param unsafeLogging Prevent logs from being scrubbed.
- @param maxPeers Capacity for number of multiplexed WebRTC peers. DEFAULTs to 1 if less than that.
- @return Port number where Snowflake will listen on, if no error happens during start up.
- */
- private long startSnowflake(String ice, String url, String fronts, String ampCache, String sqsQueueURL, String sqsCredsStr, String logFile, boolean logToStateDir, boolean keepLocalAddresses, boolean unsafeLogging, long maxPeers) {
- try {
- if (ice != null) controller.setSnowflakeIceServers(ice);
- if (url != null) controller.setSnowflakeBrokerUrl(url);
- if (fronts != null) controller.setSnowflakeFrontDomains(fronts);
- if (ampCache != null) controller.setSnowflakeAmpCacheUrl(ampCache);
- if (sqsQueueURL != null) controller.setSnowflakeSqsUrl(sqsQueueURL);
- if (sqsCredsStr != null) controller.setSnowflakeSqsCreds(sqsCredsStr);
- controller.setSnowflakeMaxPeers(5);
- controller.start(IPtProxy.Snowflake, "");
- return new URL("https://"+controller.localAddress(IPtProxy.Snowflake)).getPort();
- } catch (Exception e) {
- return -1;
- }
- }
-
- private void retryConnectionAttempt(boolean useAmpCache) {
- Log.d(TAG, ">> retryConnectionAttempt - " + (useAmpCache ? "amp cache" : "http domain fronting"));
- stopConnectionAttempt();
- startConnectionAttempt(useAmpCache, logfilePath);
- }
-
- private void watchLogFile(File logfile) {
- final Vector<String> lastBuffer = new Vector<>();
- logFileObserver = new FileObserver(logfile.getAbsolutePath()) {
- @Override
- public void onEvent(int event, @Nullable String name) {
- if (FileObserver.MODIFY == event) {
- try (Scanner scanner = new Scanner(logfile)) {
- Vector<String> currentBuffer = new Vector<>();
- while (scanner.hasNextLine()) {
- currentBuffer.add(scanner.nextLine());
- }
- if (lastBuffer.size() < currentBuffer.size()) {
- int startIndex = lastBuffer.size() > 0 ? lastBuffer.size() - 1 : 0;
- int endIndex = currentBuffer.size() - 1;
- Collection<String> newMessages = currentBuffer.subList(startIndex, endIndex);
- for (String message : newMessages) {
- logSnowflakeMessage(message);
- }
- lastBuffer.addAll(newMessages);
- }
- } catch (FileNotFoundException e) {
- e.printStackTrace();
- }
- }
- }
- };
- logFileObserver.startWatching();
- }
-
- @Override
- public void stop() {
- stopConnectionAttempt();
- if (logFileObserver != null) {
- logFileObserver.stopWatching();
- logFileObserver = null;
- }
- TorStatusObservable.getInstance().deleteObserver(this);
- controller.stop(IPtProxy.Snowflake);
- controller = null;
- handlerThread.quit();
- handler = null;
- handlerThread = null;
- }
-
- private void stopConnectionAttempt() {
- controller.stop(IPtProxy.Snowflake);
- try {
- TorStatusObservable.waitUntil(this::isSnowflakeOff, 10);
- } catch (InterruptedException | TimeoutException e) {
- e.printStackTrace();
- }
- snowflakePort = -1;
- }
-
- private boolean isSnowflakeOff() {
- return TorStatusObservable.getSnowflakeStatus() == TorStatusObservable.SnowflakeStatus.STOPPED;
- }
-
- @Override
- public String getTorrc() {
- return "UseBridges 1\n" +
- "ClientTransportPlugin snowflake socks5 127.0.0.1:" + snowflakePort + "\n" +
- "Bridge snowflake 192.0.2.3:1";
- }
-
- private void loadCdnFronts(Context context) {
- if (mFronts == null) {
- mFronts = new HashMap<>();
- }
- try {
- BufferedReader reader = new BufferedReader(new InputStreamReader(context.getAssets().open("fronts")));
- String line;
- while (true) {
- line = reader.readLine();
- if (line == null) break;
- String[] front = line.split(" ");
- mFronts.put(front[0], front[1]);
- Log.d(TAG, "front: " + front[0] + ", " + front[1]);
- }
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
-
- @Nullable
- private String getCdnFront(String service) {
- if (mFronts != null) {
- return mFronts.get(service);
- }
- return null;
- }
-
- private void logSnowflakeMessage(String message) {
- Matcher matcher = SNOWFLAKE_LOG_TIMESTAMP_PATTERN.matcher(message);
- if (matcher.matches()) {
- try {
- String strippedString = matcher.group(3).trim();
- if (!strippedString.isEmpty()) {
- TorStatusObservable.logSnowflakeMessage(contextRef.get(), strippedString);
- }
- } catch (IndexOutOfBoundsException | IllegalStateException | NullPointerException e) {
- e.printStackTrace();
- }
- } else {
- TorStatusObservable.logSnowflakeMessage(contextRef.get(), message);
- }
- }
-
- @Override
- public void propertyChange(PropertyChangeEvent evt) {
- if(TorStatusObservable.PROPERTY_CHANGE.equals(evt.getPropertyName())) {
- TorStatusObservable.SnowflakeStatus snowflakeStatus = TorStatusObservable.getSnowflakeStatus();
- if (snowflakeStatus == this.snowflakeStatus) {
- return;
- }
- if (snowflakeStatus == RETRY_HTTP_RENDEZVOUS) {
- handler.post(() -> retryConnectionAttempt(false));
- } else if (snowflakeStatus == RETRY_AMP_CACHE_RENDEZVOUS) {
- handler.post(() -> retryConnectionAttempt(true));
- }
- this.snowflakeStatus = snowflakeStatus;
- }
- }
-}
diff --git a/app/src/main/java/se/leap/bitmaskclient/tor/SnowflakePlugin.java b/app/src/main/java/se/leap/bitmaskclient/tor/SnowflakePlugin.java
new file mode 100644
index 00000000..04f43932
--- /dev/null
+++ b/app/src/main/java/se/leap/bitmaskclient/tor/SnowflakePlugin.java
@@ -0,0 +1,328 @@
+package se.leap.bitmaskclient.tor;
+/**
+ * Copyright (c) 2026 LEAP Encryption Access Project and contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import static se.leap.bitmaskclient.tor.SnowflakePlugin.RendezvousStrategy.HTTP;
+import static se.leap.bitmaskclient.tor.SnowflakePlugin.RendezvousStrategy.SQS;
+import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.RECONNECTING;
+import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.STOPPED;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+
+import org.jetbrains.annotations.Blocking;
+import org.torproject.jni.ClientTransportPluginInterface;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.TimeoutException;
+
+import IPtProxy.Controller;
+import IPtProxy.IPtProxy;
+import IPtProxy.OnTransportEvents;
+import mobilemodels.BitmaskMobileCore;
+import se.leap.bitmaskclient.R;
+import se.leap.bitmaskclient.base.utils.BitmaskCoreProvider;
+import se.leap.bitmaskclient.base.utils.PreferenceHelper;
+
+public class SnowflakePlugin implements ClientTransportPluginInterface {
+ public static String TAG = SnowflakePlugin.class.getSimpleName();
+
+ enum RendezvousStrategy {
+ HTTP,
+ // sqs queue
+ SQS,
+ //amp cache
+ AMP
+ }
+
+ private HashMap<String, String> mFronts;
+ private final WeakReference<Context> contextRef;
+ Handler handler;
+ HandlerThread handlerThread;
+ Controller controller;
+
+ /**
+ * Constructs a SnowflakePlugin instance.
+ *
+ * @param context an arbitrary context
+ * @throws IllegalStateException if initialization of the IPtProxy controller fails
+ */
+ public SnowflakePlugin(Context context) throws IllegalStateException {
+ Log.d(TAG, "initialize ClientTransport Plugin");
+ controller = initializeController(context.getApplicationContext());
+ this.contextRef = new WeakReference<>(context.getApplicationContext());
+ handlerThread = new HandlerThread("clientTransportPlugin", Thread.MIN_PRIORITY);
+ handlerThread.start();
+ handler = new Handler(handlerThread.getLooper());
+ loadFronts(context.getApplicationContext());
+ }
+
+ private Controller initializeController(Context context) throws IllegalStateException {
+ File ptDir = new File(context.getCacheDir(), "/pt_state/");
+ Controller controller = IPtProxy.newController(ptDir.getAbsolutePath(), true, false, "DEBUG", new OnTransportEvents() {
+ @Override
+ public void connected(String s) {
+ Log.d(TAG, "snowflake status callback: connected");
+ TorStatusObservable.logSnowflakeMessage(context, context.getString(R.string.log_done));
+ TorStatusObservable.setSnowflakeStatus(TorStatusObservable.SnowflakeStatus.CONNECTED);
+ }
+
+ @Override
+ public void error(String s, Exception e) {
+ Log.d(TAG, "snowflake status callback: error - " + e.getMessage());
+ TorStatusObservable.logSnowflakeMessage(context, e.getMessage());
+ TorStatusObservable.logSnowflakeMessage(context, context.getString(R.string.state_reconnecting));
+ TorStatusObservable.setSnowflakeStatus(RECONNECTING, e.getMessage());
+ }
+
+ @Override
+ public void stopped(String s, Exception e) {
+ Log.d(TAG, "snowflake status callback: stopped");
+ if (e != null && e.getMessage() != null) {
+ Log.e(TAG, e.getMessage());
+ }
+ TorStatusObservable.logSnowflakeMessage(context, context.getString(R.string.state_noprocess));
+ TorStatusObservable.setSnowflakeStatus(STOPPED);
+ }
+ });
+
+ if (controller == null) {
+ throw new IllegalStateException("Failed to initialize IPtProxy controller");
+ }
+ return controller;
+ }
+
+ /**
+ * Starts Snowflake.
+ *
+ * This method selects the best rendezvous strategy based on the user's
+ * estimated broad geographical location and initiates the connection attempt.
+ */
+ @Override
+ public void start() {
+ Context context = contextRef.get();
+ if (context == null) {
+ return;
+ }
+
+ shortenLogs(context);
+ RendezvousStrategy strategy = selectRendezvousStrategy();
+ startConnectionAttempt(strategy);
+ }
+
+ private void shortenLogs(Context context) {
+ // ensure we keep the persisted logfile short
+ File logfile = new File(context.getApplicationContext().getCacheDir(), "/pt_state/" + IPtProxy.LogFileName);
+ try {
+ if (logfile.exists()) {
+ logfile.delete();
+ }
+ logfile.createNewFile();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Attempt to select best rendezvous strategy depending on users broad location.
+ * @return a snowflake RendezvousStrategy
+ */
+ private RendezvousStrategy selectRendezvousStrategy() {
+ Random random = new Random();
+ ArrayList<RendezvousStrategy> strategies = new ArrayList<>(Arrays.asList(RendezvousStrategy.values()));
+ String countryCode = getCountryCode();
+ if ("RU".equals(countryCode) || "CN".equals(countryCode)) {
+ strategies.remove(RendezvousStrategy.HTTP);
+ } else {
+ strategies.remove(SQS);
+ }
+ int randomIndex = random.nextInt(strategies.size());
+ return strategies.get(randomIndex);
+ }
+
+ /**
+ * Attempt to determine the country code from the users IP address
+ * @return country code or null if geoip lookup failed
+ */
+ private String getCountryCode() {
+ try {
+ String geoIPLookupURL = PreferenceHelper.getGeoIPLookupURL();
+ if (geoIPLookupURL != null) {
+ BitmaskMobileCore bm = BitmaskCoreProvider.getBitmaskMobile();
+ String stunServers = getFront(HTTP, "ice").replaceAll("stun:", "");
+ List<String> serverList = Arrays.asList(stunServers.split(","));
+ Collections.shuffle(serverList);
+ stunServers = String.join(",", serverList);
+ bm.setStunServers(stunServers);
+ bm.setCountryCodeLookupURL(geoIPLookupURL);
+ return bm.getGeolocation();
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return PreferenceHelper.getBaseCountry();
+ }
+
+ private void startConnectionAttempt(RendezvousStrategy strategy) {
+ try {
+ String target = null;
+ String fronts = null;
+ String ampCache = null;
+ String sqsQueue = null;
+ String sqsCreds = null;
+ String stunServer = getFront(strategy, "ice");
+ switch (strategy) {
+ case HTTP -> {
+ target = getFront(strategy, "url");
+ fronts = getFront(strategy, "fronts");
+ }
+ case AMP -> {
+ target = getFront(strategy, "url");
+ fronts = getFront(strategy, "fronts");
+ ampCache = getFront(strategy, "ampcache");
+ }
+ case SQS -> {
+ sqsCreds = getFront(strategy, "sqscreds");
+ sqsQueue = getFront(strategy, "sqsqueue");
+ }
+ }
+
+
+ startSnowflake(stunServer, target, fronts, ampCache, sqsQueue, sqsCreds, 5);
+ } catch (NullPointerException npe) {
+ Log.e(TAG, "failed to start Snowflake: " + npe.getMessage());
+ Context c = contextRef.get();
+ if (c != null) {
+ TorStatusObservable.logSnowflakeMessage(c, npe.getMessage());
+ }
+ TorStatusObservable.setSnowflakeStatus(STOPPED);
+ }
+ }
+
+/**
+ StartSnowflake - Start IPtProxy's Snowflake client.
+ @param ice Comma-separated list of ICE servers.
+ @param url URL of signaling broker.
+ @param fronts Comma-separated list of front domains.
+ @param ampCache OPTIONAL. URL of AMP cache to use as a proxy for signaling.
+ Only needed when you want to do the rendezvous over AMP instead of a domain fronted server.
+ @param sqsQueueURL OPTIONAL. URL of SQS Queue to use as a proxy for signaling.
+ @param sqsCredsStr OPTIONAL. Credentials to access SQS Queue
+ @param maxPeers Capacity for number of multiplexed WebRTC peers. DEFAULTs to 1 if less than that.
+ */
+ private void startSnowflake(String ice, String url, String fronts, String ampCache, String sqsQueueURL, String sqsCredsStr, long maxPeers) {
+ Context c = contextRef.get();
+ try {
+ Log.d(TAG, "start snowflake with ice " + ice + "\nurl: " + url + "\nfronts: " + fronts + "\nampCache: " + ampCache + "\nsqsQueue: " + sqsQueueURL);
+ if (ice != null) controller.setSnowflakeIceServers(ice);
+ if (url != null) controller.setSnowflakeBrokerUrl(url);
+ if (fronts != null) controller.setSnowflakeFrontDomains(fronts);
+ if (ampCache != null) controller.setSnowflakeAmpCacheUrl(ampCache);
+ if (sqsQueueURL != null) controller.setSnowflakeSqsUrl(sqsQueueURL);
+ if (sqsCredsStr != null) controller.setSnowflakeSqsCreds(sqsCredsStr);
+ controller.setSnowflakeMaxPeers(maxPeers);
+ controller.start(IPtProxy.Snowflake, "");
+ if (c != null) {
+ TorStatusObservable.logSnowflakeMessage(c, c.getString(R.string.snowflake_started));
+ }
+ TorStatusObservable.setSnowflakeStatus(TorStatusObservable.SnowflakeStatus.STARTED);
+ } catch (Exception e) {
+ Log.e(TAG, this.toString() + "failed to start Snowflake: " + e.getMessage());
+ if (c != null) {
+ TorStatusObservable.logSnowflakeMessage(c, e.getMessage());
+ }
+ TorStatusObservable.setSnowflakeStatus(STOPPED);
+ }
+ }
+
+ /**
+ * Stops Snowflake connection.
+ * This method is blocking the current thread until snowflake was stopped or the timeout reached.
+ */
+ @Blocking
+ @Override
+ public void stop() {
+ controller.stop(IPtProxy.Snowflake);
+ try {
+ TorStatusObservable.waitUntil(this::isSnowflakeOff, 10);
+ } catch (InterruptedException | TimeoutException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private boolean isSnowflakeOff() {
+ return TorStatusObservable.getSnowflakeStatus() == TorStatusObservable.SnowflakeStatus.STOPPED;
+ }
+
+ /**
+ * Retrieves the Tor configuration string for the Snowflake pluggable transport.
+ *
+ * @return a Tor configuration string if the controller is running, otherwise an empty string
+ */
+ @Override
+ public String getTorrc() {
+ if (controller.port(IPtProxy.Snowflake) <= 0) {
+ return "";
+ }
+ return "UseBridges 1\n" +
+ "ClientTransportPlugin snowflake socks5 127.0.0.1:" + controller.port(IPtProxy.Snowflake) + "\n" +
+ "Bridge snowflake 192.0.2.3:1";
+ }
+
+ private void loadFronts(Context context) {
+ if (mFronts == null) {
+ mFronts = new HashMap<>();
+ }
+ for (RendezvousStrategy strategy : RendezvousStrategy.values()) {
+ try {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(context.getAssets().open("fronts-"+strategy.toString().toLowerCase())));
+ String line;
+ while (true) {
+ line = reader.readLine();
+ if (line == null) break;
+ String[] front = line.split(" ");
+ mFronts.put(strategy+front[0], front[1]);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ }
+
+ private String getFront(RendezvousStrategy strategy, String arg) throws NullPointerException {
+ if (mFronts != null) {
+ String front = mFronts.get(strategy+arg);
+ if (front != null) {
+ return front;
+ }
+ }
+ throw new NullPointerException("expected value "+ arg + " for rendezvous strategy " + strategy + " not found.");
+ }
+}
diff --git a/app/src/main/java/se/leap/bitmaskclient/tor/TorServiceCommand.java b/app/src/main/java/se/leap/bitmaskclient/tor/TorServiceCommand.java
index 4c6ddaba..d837f58c 100644
--- a/app/src/main/java/se/leap/bitmaskclient/tor/TorServiceCommand.java
+++ b/app/src/main/java/se/leap/bitmaskclient/tor/TorServiceCommand.java
@@ -33,6 +33,7 @@ import org.torproject.jni.TorService;
import java.util.concurrent.TimeoutException;
+import se.leap.bitmaskclient.base.utils.ClientTransportPluginProvider;
import se.leap.bitmaskclient.base.utils.PreferenceHelper;
public class TorServiceCommand {
@@ -159,7 +160,7 @@ public class TorServiceCommand {
if (PreferenceHelper.getUseSnowflake()) {
Log.d(TAG, "serviceConnection is still null");
if (!TorService.hasClientTransportPlugin()) {
- TorService.setClientTransportPlugin(new ClientTransportPlugin(context.getApplicationContext()));
+ TorService.setClientTransportPlugin(ClientTransportPluginProvider.get());
}
return new TorServiceConnection(context);
}
diff --git a/app/src/main/java/se/leap/bitmaskclient/tor/TorStatusObservable.java b/app/src/main/java/se/leap/bitmaskclient/tor/TorStatusObservable.java
index 5a49fda2..92e74a9e 100644
--- a/app/src/main/java/se/leap/bitmaskclient/tor/TorStatusObservable.java
+++ b/app/src/main/java/se/leap/bitmaskclient/tor/TorStatusObservable.java
@@ -15,13 +15,7 @@ package se.leap.bitmaskclient.tor;
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.BROKER_REPLIED_SUCCESS;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.NEGOTIATING_RENDEZVOUS_VIA_AMP_CACHE;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.NEGOTIATING_RENDEZVOUS_VIA_HTTP;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.RETRY_AMP_CACHE_RENDEZVOUS;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.RETRY_HTTP_RENDEZVOUS;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.SENDING_DATA;
-import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.STARTED;
+
import static se.leap.bitmaskclient.tor.TorStatusObservable.SnowflakeStatus.STOPPED;
import android.content.Context;
@@ -59,12 +53,8 @@ public class TorStatusObservable {
public enum SnowflakeStatus {
STARTED,
- NEGOTIATING_RENDEZVOUS_VIA_HTTP,
- NEGOTIATING_RENDEZVOUS_VIA_AMP_CACHE,
- RETRY_HTTP_RENDEZVOUS,
- RETRY_AMP_CACHE_RENDEZVOUS,
- BROKER_REPLIED_SUCCESS,
- SENDING_DATA,
+ CONNECTED,
+ RECONNECTING,
STOPPED
}
@@ -72,21 +62,6 @@ public class TorStatusObservable {
// the TorService has sent the shutdown signal
private boolean cancelled = false;
- public static final String LOG_TAG_TOR = "[TOR]";
- public static final String LOG_TAG_SNOWFLAKE = "[SNOWFLAKE]";
- public static final String SNOWFLAKE_STARTED = "--- Starting Snowflake Client ---";
- public static final String SNOWFLAKE_STOPPED_COLLECTING = "---- SnowflakeConn: end collecting snowflakes ---";
- public static final String SNOWFLAKE_COPY_LOOP_STOPPED = "copy loop ended";
- public static final String SNOWFLAKE_SOCKS_ERROR = "SOCKS accept error";
- public static final String SNOWFLAKE_NEGOTIATING_HTTP = "Negotiating via HTTP rendezvous...";
- public static final String SNOWFLAKE_NEGOTIATING_AMP_CACHE = "Negotiating via AMP cache rendezvous...";
- public static final String SNOWFLAKE_CONNECTION_CLOSING = "WebRTC: Closing";
- public static final String SNOWFLAKE_HTTP_RESPONSE_200 = "HTTP rendezvous response: 200";
- public static final String SNOWFLAKE_AMP_CACHE_RESPONSE_200 = "AMP cache rendezvous response: 200";
-
- public static final String SNOWFLAKE_SENDING_DATA = "Traffic Bytes (in|out):";
-
-
private static TorStatusObservable instance;
private TorStatus status = TorStatus.OFF;
private SnowflakeStatus snowflakeStatus = STOPPED;
@@ -97,7 +72,6 @@ public class TorStatusObservable {
private int port = -1;
private int socksPort = -1;
private int bootstrapPercent = -1;
- private int retrySnowflakeRendezVous = 0;
private final Vector<String> lastLogs = new Vector<>(100);
private TorStatusObservable() {
@@ -120,6 +94,18 @@ public class TorStatusObservable {
return getInstance().snowflakeStatus;
}
+ public static void setSnowflakeStatus(TorStatusObservable.SnowflakeStatus snowflakeStatus) {
+ setSnowflakeStatus(snowflakeStatus, null);
+ }
+
+ public static void setSnowflakeStatus(TorStatusObservable.SnowflakeStatus snowflakeStatus, String snowflakeLog) {
+ if (snowflakeLog != null) {
+ getInstance().lastSnowflakeLog = snowflakeLog;
+ }
+ getInstance().snowflakeStatus = snowflakeStatus;
+ instance.notifyObservers();
+ }
+
/**
* Waits on the current Thread until a certain tor/snowflake status has been reached
* @param condition defines when wait should be interrupted
@@ -163,46 +149,8 @@ public class TorStatusObservable {
if (getInstance().status != TorStatus.OFF) {
getInstance().torNotificationManager.buildTorNotification(context, getStringForCurrentStatus(context), getNotificationLog(), getBootstrapProgress());
}
- //TODO: implement proper state signalling in IPtProxy
message = message.trim();
- if (SNOWFLAKE_STARTED.equals(message)) {
- getInstance().snowflakeStatus = STARTED;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_started);
- } else if (SNOWFLAKE_NEGOTIATING_HTTP.equals(message)) {
- getInstance().snowflakeStatus = NEGOTIATING_RENDEZVOUS_VIA_HTTP;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_negotiating_rendezvous_http);
- } else if (SNOWFLAKE_NEGOTIATING_AMP_CACHE.equals(message)) {
- getInstance().snowflakeStatus = NEGOTIATING_RENDEZVOUS_VIA_AMP_CACHE;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_negotiating_rendezvous_amp_cache);
- } else if (SNOWFLAKE_STOPPED_COLLECTING.equals(message) ||
- SNOWFLAKE_COPY_LOOP_STOPPED.equals(message) ||
- message.contains(SNOWFLAKE_SOCKS_ERROR)) {
- getInstance().snowflakeStatus = STOPPED;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_socks_error);
- } else if (SNOWFLAKE_CONNECTION_CLOSING.equals(message)) {
- if (getInstance().snowflakeStatus == NEGOTIATING_RENDEZVOUS_VIA_HTTP) {
- if (getInstance().retrySnowflakeRendezVous < 3) {
- getInstance().retrySnowflakeRendezVous += 1;
- } else {
- getInstance().retrySnowflakeRendezVous = 0;
- getInstance().snowflakeStatus = RETRY_AMP_CACHE_RENDEZVOUS;
- }
- } else if (getInstance().snowflakeStatus == NEGOTIATING_RENDEZVOUS_VIA_AMP_CACHE) {
- if (getInstance().retrySnowflakeRendezVous < 3) {
- getInstance().retrySnowflakeRendezVous += 1;
- } else {
- getInstance().retrySnowflakeRendezVous = 0;
- getInstance().snowflakeStatus = RETRY_HTTP_RENDEZVOUS;
- }
- }
- } else if (SNOWFLAKE_AMP_CACHE_RESPONSE_200.equals(message) || SNOWFLAKE_HTTP_RESPONSE_200.equals(message)) {
- getInstance().snowflakeStatus = BROKER_REPLIED_SUCCESS;
- getInstance().retrySnowflakeRendezVous = 0;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_broker_success);
- } else if (message.contains(SNOWFLAKE_SENDING_DATA)) {
- getInstance().snowflakeStatus = SENDING_DATA;
- getInstance().lastSnowflakeLog = context.getString(R.string.snowflake_sending_data);
- }
+ getInstance().lastSnowflakeLog = message;
Log.d(TAG, "snowflake status " + getInstance().snowflakeStatus);
instance.notifyObservers();
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 845af01c..7953caf5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -227,11 +227,6 @@
<string name="tor_status">Tor Status</string>
<string name="snowflake_status">Snowflake Status</string>
<string name="snowflake_started">Snowflake client started</string>
- <string name="snowflake_negotiating_rendezvous_http">Negotiating Snowflake proxy rendezvous (http)</string>
- <string name="snowflake_negotiating_rendezvous_amp_cache">Negotiating Snowflake proxy rendezvous (amp cache)</string>
- <string name="snowflake_socks_error">Snowflake SOCKS error</string>
- <string name="snowflake_broker_success">Snowflake proxy rendezvous successful</string>
- <string name="snowflake_sending_data">Sending data via Snowflake</string>
<string name="title_upcoming_connection_request">Upcoming Connection Request</string>
<string name="title_upcoming_request">Upcoming requests</string>
<string name="title_upcoming_request_summary">In the next panels Android will ask for your permission in form of a Connection Request and Notification Request.</string>