Merge pull request #3368 from vitorpamplona/claude/eager-albattani-p3sxmq

Add Onion-Location support for transparent Tor routing
This commit is contained in:
Vitor Pamplona
2026-06-25 16:57:48 -04:00
committed by GitHub
9 changed files with 321 additions and 2 deletions
@@ -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
@@ -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 ->
@@ -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 ->
@@ -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))
@@ -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))
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
}