mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3368 from vitorpamplona/claude/eager-albattani-p3sxmq
Add Onion-Location support for transparent Tor routing
This commit is contained in:
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
|
||||
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -240,6 +241,11 @@ class AppModules(
|
||||
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
|
||||
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
|
||||
|
||||
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
|
||||
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
|
||||
// on Tor-enabled clients to transparently redirect to .onion addresses.
|
||||
val onionLocationCache = OnionLocationCache()
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients: DualHttpClientManager =
|
||||
DualHttpClientManager(
|
||||
@@ -258,6 +264,7 @@ class AppModules(
|
||||
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
@@ -432,6 +439,7 @@ class AppModules(
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
scope = applicationIOScope,
|
||||
dns = surgeDns,
|
||||
onionCache = onionLocationCache,
|
||||
)
|
||||
|
||||
// Connects the INostrClient class with okHttp
|
||||
|
||||
+2
-1
@@ -40,8 +40,9 @@ class DualHttpClientManager(
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
onionCache: OnionLocationCache? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+2
-1
@@ -36,8 +36,9 @@ class DualHttpClientManagerForRelays(
|
||||
isMobileDataProvider: StateFlow<Boolean?>,
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
onionCache: OnionLocationCache,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactoryForRelays(userAgent, dns)
|
||||
val factory = OkHttpClientFactoryForRelays(userAgent, dns, onionCache)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+3
@@ -43,6 +43,7 @@ class OkHttpClientFactory(
|
||||
* useful for tests or pre-configuration call sites.
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -91,6 +92,7 @@ class OkHttpClientFactory(
|
||||
}
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
.apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } }
|
||||
.build()
|
||||
|
||||
private var lastProxy: Proxy? = null
|
||||
@@ -107,6 +109,7 @@ class OkHttpClientFactory(
|
||||
return rootClient
|
||||
.newBuilder()
|
||||
.proxy(proxy)
|
||||
.apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } }
|
||||
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ import java.time.Duration
|
||||
class OkHttpClientFactoryForRelays(
|
||||
userAgent: String,
|
||||
private val dns: SurgeDns,
|
||||
private val onionCache: OnionLocationCache,
|
||||
) {
|
||||
companion object {
|
||||
// by picking a random proxy port, the connection will fail as it should.
|
||||
@@ -61,6 +62,7 @@ class OkHttpClientFactoryForRelays(
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.addInterceptor(OnionLocationInterceptor(onionCache))
|
||||
.build()
|
||||
|
||||
private var lastProxy: Proxy? = null
|
||||
@@ -77,6 +79,7 @@ class OkHttpClientFactoryForRelays(
|
||||
return rootClient
|
||||
.newBuilder()
|
||||
.proxy(proxy)
|
||||
.apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) }
|
||||
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Maps clearnet hostnames to their advertised .onion equivalents, populated
|
||||
* passively by [OnionLocationInterceptor] from any HTTP/WebSocket response
|
||||
* that carries an `Onion-Location` header.
|
||||
*
|
||||
* The [OnionUrlRewriteInterceptor] consults this cache on every outbound
|
||||
* request when Tor is active, transparently redirecting to the .onion address
|
||||
* so Tor-routed connections avoid exit nodes entirely.
|
||||
*
|
||||
* Entries expire after [TTL_MS] so that a server that rotates or decommissions
|
||||
* its `.onion` address does not permanently break Tor connectivity. After expiry
|
||||
* the next request goes out via Tor exit nodes (or clearnet on the no-proxy
|
||||
* client); if the server still advertises `Onion-Location` the mapping is
|
||||
* refreshed automatically.
|
||||
*/
|
||||
class OnionLocationCache {
|
||||
private data class Entry(
|
||||
val onionUrl: String,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<String, Entry>()
|
||||
|
||||
fun put(
|
||||
host: String,
|
||||
onionUrl: String,
|
||||
) {
|
||||
cache[host] = Entry(onionUrl, System.currentTimeMillis() + TTL_MS)
|
||||
}
|
||||
|
||||
fun get(host: String): String? {
|
||||
val entry = cache[host] ?: return null
|
||||
if (System.currentTimeMillis() > entry.expiresAtMs) {
|
||||
cache.remove(host)
|
||||
return null
|
||||
}
|
||||
return entry.onionUrl
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TTL_MS: Long = TimeUnit.HOURS.toMillis(24)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Application interceptor that passively captures `Onion-Location` headers from
|
||||
* every HTTP response — NIP-11 documents, WebSocket upgrade handshakes (101),
|
||||
* image/media servers, anything — and records the mapping into
|
||||
* [OnionLocationCache].
|
||||
*
|
||||
* No extra requests are made; discovery is a free by-product of traffic that
|
||||
* already happens. When the Tor-enabled client later connects to the same host,
|
||||
* [OnionUrlRewriteInterceptor] swaps in the cached .onion address so the
|
||||
* connection avoids exit nodes entirely.
|
||||
*
|
||||
* Registered as an application interceptor so that `chain.request().url.host`
|
||||
* always returns the original clearnet hostname — the correct cache key. If it
|
||||
* were a network interceptor it would run after [OnionUrlRewriteInterceptor]
|
||||
* has already rewritten the host to `.onion`, causing cache entries from
|
||||
* onion-routed responses to be stored under the `.onion` hostname instead of
|
||||
* the clearnet hostname that [OnionUrlRewriteInterceptor] actually looks up.
|
||||
*/
|
||||
class OnionLocationInterceptor(
|
||||
private val cache: OnionLocationCache,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
val onionLocation = response.header("Onion-Location")
|
||||
if (onionLocation != null) {
|
||||
cache.put(chain.request().url.host, onionLocation)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Application interceptor that rewrites outbound requests to use a known
|
||||
* .onion address when one is available in [OnionLocationCache].
|
||||
*
|
||||
* Only added to Tor-enabled OkHttpClient instances, so .onion substitution
|
||||
* never happens on clearnet connections (where .onion DNS would not resolve).
|
||||
*
|
||||
* Scheme is derived from the `Onion-Location` header value to keep the request
|
||||
* in the correct protocol family:
|
||||
* - WebSocket originals (`ws`/`wss`) → `ws` or `wss` based on the onion scheme
|
||||
* - HTTP originals (`http`/`https`) → `http` or `https` based on the onion scheme
|
||||
*
|
||||
* A `.onion` server advertising `http://` for a clearnet `https://` host is
|
||||
* intentional and safe: the Tor circuit provides end-to-end encryption
|
||||
* equivalent to TLS for onion services, so an `http://` onion connection is
|
||||
* not a downgrade in practice.
|
||||
*
|
||||
* Port and path are preserved from the onion URL and original request
|
||||
* respectively. An unparseable cached URL falls through to the original.
|
||||
*/
|
||||
class OnionUrlRewriteInterceptor(
|
||||
private val cache: OnionLocationCache,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val onionUrlStr = cache.get(original.url.host) ?: return chain.proceed(original)
|
||||
val onionUrl = runCatching { onionUrlStr.toHttpUrl() }.getOrNull() ?: return chain.proceed(original)
|
||||
|
||||
val newScheme =
|
||||
when (original.url.scheme) {
|
||||
"ws", "wss" -> if (onionUrl.scheme == "https") "wss" else "ws"
|
||||
"http", "https" -> if (onionUrl.scheme == "https") "https" else "http"
|
||||
else -> onionUrl.scheme
|
||||
}
|
||||
|
||||
val rewritten =
|
||||
original
|
||||
.newBuilder()
|
||||
.url(
|
||||
original.url
|
||||
.newBuilder()
|
||||
.scheme(newScheme)
|
||||
.host(onionUrl.host)
|
||||
.port(onionUrl.port)
|
||||
.build(),
|
||||
).build()
|
||||
|
||||
return chain.proceed(rewritten)
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies that OkHttp 5.x HttpUrl.toHttpUrl() accepts .onion pseudo-TLD hostnames.
|
||||
*
|
||||
* .onion is not an IANA-registered TLD (RFC 7686), so it may be rejected by strict
|
||||
* domain validators. If toHttpUrl() rejects .onion URLs the entire OnionUrlRewriteInterceptor
|
||||
* feature silently falls through to the clearnet address. This test documents and guards
|
||||
* the actual OkHttp behavior so any upgrade that breaks .onion support is caught.
|
||||
*/
|
||||
class OnionUrlParseTest {
|
||||
// A real Tor v3 .onion address (56 base32 chars + ".onion")
|
||||
private val torV3Host = "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion"
|
||||
|
||||
@Test
|
||||
fun torV3HttpUrl_parsesSuccessfully() {
|
||||
val url = runCatching { "http://$torV3Host/".toHttpUrl() }.getOrNull()
|
||||
assertNotNull(
|
||||
"OkHttp must accept Tor v3 .onion hosts — if null, OnionUrlRewriteInterceptor is silently broken",
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torV3HttpUrl_hostIsPreserved() {
|
||||
val url = "http://$torV3Host/".toHttpUrl()
|
||||
assertEquals(torV3Host, url.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torV3HttpsUrl_parsesSuccessfully() {
|
||||
val url = runCatching { "https://$torV3Host/".toHttpUrl() }.getOrNull()
|
||||
assertNotNull(url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun torV3WithPort_parsesSuccessfully() {
|
||||
val url = runCatching { "http://$torV3Host:8080/path".toHttpUrl() }.getOrNull()
|
||||
assertNotNull(url)
|
||||
assertEquals(8080, url?.port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortOnionLabel_parsesSuccessfully() {
|
||||
// Shorter label (e.g. Tor v2-style) — also must be accepted
|
||||
val url = runCatching { "http://relay.someservice.onion/".toHttpUrl() }.getOrNull()
|
||||
assertNotNull(url)
|
||||
}
|
||||
|
||||
// --- Regression: OnionUrlRewriteInterceptor scheme translation ---
|
||||
|
||||
@Test
|
||||
fun wsOriginalWithHttpOnion_rewritesToWs() {
|
||||
val onionUrlStr = "http://$torV3Host/"
|
||||
val onionUrl = onionUrlStr.toHttpUrl()
|
||||
val originalScheme = "ws"
|
||||
val newScheme =
|
||||
if (originalScheme == "ws" || originalScheme == "wss") {
|
||||
if (onionUrl.scheme == "https") "wss" else "ws"
|
||||
} else {
|
||||
onionUrl.scheme
|
||||
}
|
||||
assertEquals("ws", newScheme)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wssOriginalWithHttpsOnion_rewritesToWss() {
|
||||
val onionUrlStr = "https://$torV3Host/"
|
||||
val onionUrl = onionUrlStr.toHttpUrl()
|
||||
val originalScheme = "wss"
|
||||
val newScheme =
|
||||
if (originalScheme == "ws" || originalScheme == "wss") {
|
||||
if (onionUrl.scheme == "https") "wss" else "ws"
|
||||
} else {
|
||||
onionUrl.scheme
|
||||
}
|
||||
assertEquals("wss", newScheme)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user