Skip to content

Commit b6f9985

Browse files
authored
Merge pull request #1416 from tladesignz/upnp
Added support for UPnP IGD to automatically open up ports for Snowflake Proxy…
2 parents 4d735d1 + ace1a55 commit b6f9985

File tree

4 files changed

+73
-21
lines changed

4 files changed

+73
-21
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
</intent>
3535
</queries>
3636

37+
<!--
38+
usesCleartextTraffic is needed for SSDP multicast local router discovery (which is an unencrypted
39+
HTTP-style protocol) and for UPnP IGD requests to map ports for Snowflake Proxy, (which are also
40+
unencrypted HTTP-style requests).
41+
-->
3742
<application
3843
android:name=".OrbotApp"
3944
android:supportsRtl="true"
@@ -42,6 +47,7 @@
4247
android:configChanges="locale|orientation|screenSize"
4348
android:description="@string/app_description"
4449
android:hasFragileUserData="false"
50+
android:usesCleartextTraffic="true"
4551
android:icon="@mipmap/ic_launcher"
4652
android:label="@string/app_name"
4753
android:taskAffinity=""

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ app-icon-name-changer = "1.0.7"
2929
androidx-biometric = "1.1.0"
3030
androidx-lifecycle = "2.9.3"
3131
screengrab = "2.1.1"
32+
upnp = "1.0.0"
3233

3334
[libraries]
3435
android-material = { group = "com.google.android.material", name = "material", version.ref = "android-material" }
@@ -64,6 +65,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version
6465
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" }
6566
androidx-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common", version.ref = "androidx-lifecycle" }
6667
screengrab = { group = "tools.fastlane", name = "screengrab", version.ref = "screengrab" }
68+
upnp = { group = "com.netzarchitekten", name = "upnp", version.ref = "upnp" }
6769

6870
[plugins]
6971
android-application = { id = "com.android.application", version.ref = "agp" }

orbotservice/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,5 @@ dependencies {
6767
implementation(libs.kotlinx.serialization.json)
6868
implementation(libs.retrofit.converter)
6969
implementation(libs.retrofit.lib)
70+
implementation(libs.upnp)
7071
}

orbotservice/src/main/java/org/torproject/android/service/circumvention/SnowflakeProxyWrapper.kt

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,26 @@ import IPtProxy.SnowflakeClientConnected
44
import IPtProxy.SnowflakeProxy
55
import android.content.Context
66
import android.os.Handler
7+
import com.netzarchitekten.upnp.UPnP
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.launch
11+
import kotlinx.coroutines.withContext
12+
import org.torproject.android.service.OrbotConstants
713
import org.torproject.android.service.OrbotConstants.ONION_EMOJI
814
import org.torproject.android.service.OrbotService
915
import org.torproject.android.service.R
1016
import org.torproject.android.service.util.Prefs
1117
import org.torproject.android.service.util.showToast
1218
import java.security.SecureRandom
19+
import kotlin.random.Random
1320

1421
class SnowflakeProxyWrapper(private val context: Context) {
22+
1523
private var proxy: SnowflakeProxy? = null
1624

25+
private var mappedPorts = mutableListOf<Int>()
26+
1727
@Synchronized
1828
fun enableProxy(
1929
hasWifi: Boolean,
@@ -22,37 +32,63 @@ class SnowflakeProxyWrapper(private val context: Context) {
2232
if (proxy != null) return
2333
if (Prefs.limitSnowflakeProxyingWifi() && !hasWifi) return
2434
if (Prefs.limitSnowflakeProxyingCharging() && !hasPower) return
25-
proxy = SnowflakeProxy()
26-
val stunServers = BuiltInBridges.getInstance(context)?.snowflake?.firstOrNull()?.ice
27-
?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() ?: emptyArray()
28-
val stunUrl = stunServers[SecureRandom().nextInt(stunServers.size)]
29-
30-
proxy = SnowflakeProxy()
31-
with(proxy!!) {
32-
brokerUrl = OrbotService.getCdnFront("snowflake-target-direct")
33-
capacity = 1L
34-
pollInterval = 120L
35-
stunServer = stunUrl
36-
relayUrl = OrbotService.getCdnFront("snowflake-relay-url")
37-
natProbeUrl = OrbotService.getCdnFront("snowflake-nat-probe")
38-
clientConnected = SnowflakeClientConnected { onConnected() }
39-
start()
40-
}
4135

42-
if (Prefs.showSnowflakeProxyMessage()) {
43-
val message = context.getString(R.string.log_notice_snowflake_proxy_enabled)
44-
Handler(context.mainLooper).post {
45-
context.applicationContext.showToast(message)
36+
CoroutineScope(Dispatchers.IO).launch {
37+
val start = Random.nextInt(49152, 65536 - 2)
38+
39+
for (port in (start..start + 2)) {
40+
if (UPnP.openPortUDP(port, OrbotConstants.TAG)) {
41+
mappedPorts.add(port)
42+
}
43+
}
44+
45+
// Snowflake Proxy needs Capacity * 2 + 1 = 3 consecutive ports mapped for unrestricted mode.
46+
// If we can't get all of these, remove the ones we have and
47+
// rather have Snowflake Proxy run in restricted mode.
48+
if (mappedPorts.size < 3) {
49+
releaseMappedPorts()
50+
}
51+
52+
val stunServers = BuiltInBridges.getInstance(context)?.snowflake?.firstOrNull()?.ice
53+
?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() ?: emptyArray()
54+
val stunUrl = stunServers[SecureRandom().nextInt(stunServers.size)]
55+
56+
proxy = SnowflakeProxy()
57+
with(proxy!!) {
58+
brokerUrl = OrbotService.getCdnFront("snowflake-target-direct")
59+
capacity = 1L
60+
pollInterval = 120L
61+
stunServer = stunUrl
62+
relayUrl = OrbotService.getCdnFront("snowflake-relay-url")
63+
natProbeUrl = OrbotService.getCdnFront("snowflake-nat-probe")
64+
clientConnected = SnowflakeClientConnected { onConnected() }
65+
66+
// TODO: Activate, when new IPtProxy is available.
67+
// Setting these to null or 0 is equivalent to not setting this at all.
68+
// ephemeralMinPort = mappedPorts.firstOrNull()
69+
// ephemeralMaxPort = mappedPorts.lastOrNull()
70+
71+
start()
72+
}
73+
74+
if (Prefs.showSnowflakeProxyMessage()) {
75+
val message = context.getString(R.string.log_notice_snowflake_proxy_enabled)
76+
77+
withContext(Dispatchers.Main) {
78+
context.applicationContext.showToast(message)
79+
}
4680
}
4781
}
4882
}
4983

5084
@Synchronized
5185
fun stopProxy() {
5286
if (proxy == null) return
53-
proxy!!.stop()
87+
proxy?.stop()
5488
proxy = null
5589

90+
releaseMappedPorts()
91+
5692
if (Prefs.showSnowflakeProxyMessage()) {
5793
val message = context.getString(R.string.log_notice_snowflake_proxy_disabled)
5894
Handler(context.mainLooper).post {
@@ -72,6 +108,13 @@ class SnowflakeProxyWrapper(private val context: Context) {
72108
Handler(context.mainLooper).post {
73109
context.applicationContext.showToast(message)
74110
}
111+
}
112+
113+
private fun releaseMappedPorts() {
114+
for (port in mappedPorts) {
115+
UPnP.closePortUDP(port)
116+
}
75117

118+
mappedPorts = mutableListOf()
76119
}
77120
}

0 commit comments

Comments
 (0)