mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge PR: fix(tor): wire Onion-Location interceptors into every OkHttp client
Merges nostr proposal e5865428 (v2) into main: - fix(tor): wire Onion-Location interceptors into every OkHttp client (onionCache made non-nullable; OnionInterceptorWiringTest) - refactor(napplet): route blob fetches through the shared OkHttpClientFactory - fix(napplet): route brokered resource fetches by the applet's own Tor mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -320,7 +320,9 @@ class NappletBrokerService : Service() {
|
||||
context = applicationContext,
|
||||
ledger = ledger,
|
||||
storage = storage,
|
||||
torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 },
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
|
||||
+8
-3
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.ByteArrayInputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -70,10 +71,14 @@ class AccountNappletGateways(
|
||||
private val context: Context,
|
||||
private val ledger: NappletPermissionLedger,
|
||||
private val storage: NappletStorage,
|
||||
private val torPort: () -> Int,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
private val consentSummary = NappletConsentSummary(context)
|
||||
private val resourceFetcher = NappletResourceFetcher(account, torPort)
|
||||
|
||||
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
|
||||
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
|
||||
// rest of the app, instead of a private client that has to re-wire all of it.
|
||||
private val resourceFetcher = NappletResourceFetcher(account, httpClient)
|
||||
private val identityReader = AccountIdentityReader(account)
|
||||
|
||||
fun broker(): NappletBroker {
|
||||
@@ -97,7 +102,7 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) }
|
||||
val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) }
|
||||
val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) }
|
||||
val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) }
|
||||
val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) }
|
||||
val theme = NappletThemeGateway { currentThemeColors() }
|
||||
|
||||
+27
-34
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
import android.util.Base64
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
@@ -40,8 +41,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
@@ -50,25 +49,34 @@ import java.net.URLDecoder
|
||||
* `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before
|
||||
* returning, so a wrong server can never substitute the blob.
|
||||
*
|
||||
* Owns a Tor-routed [OkHttpClient], cached and rebuilt only when the active Tor port ([torPort])
|
||||
* changes. Built per account (so it reads the right Blossom server list); consent is enforced by the
|
||||
* Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared
|
||||
* [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than
|
||||
* standing up a private client — means napplet blob fetches inherit the same passive
|
||||
* `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool
|
||||
* and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling
|
||||
* applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the
|
||||
* user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads.
|
||||
* Built per account (so it reads the right Blossom server list); consent is enforced by the
|
||||
* broker before [fetch] ever runs.
|
||||
*/
|
||||
class NappletResourceFetcher(
|
||||
private val account: Account,
|
||||
private val torPort: () -> Int,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
// Reused blob HTTP client, keyed by the active Tor port (see client()).
|
||||
private var cachedHttp: Pair<Int, OkHttpClient>? = null
|
||||
|
||||
/** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */
|
||||
suspend fun fetch(url: String): NappletResource? =
|
||||
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource? =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
|
||||
when {
|
||||
url.startsWith("data:") -> decodeDataUrl(url)
|
||||
url.startsWith("https://") -> {
|
||||
runCatching {
|
||||
client()
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
@@ -84,7 +92,7 @@ class NappletResourceFetcher(
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
url.startsWith("blossom:") -> fetchBlossom(url)
|
||||
url.startsWith("blossom:") -> fetchBlossom(url, client)
|
||||
url.startsWith("nostr:") -> resolveNostr(url)
|
||||
else -> null
|
||||
}
|
||||
@@ -141,30 +149,16 @@ class NappletResourceFetcher(
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network).
|
||||
* Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun client(): OkHttpClient {
|
||||
val port = torPort()
|
||||
cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client }
|
||||
val client =
|
||||
if (port > 0) {
|
||||
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build()
|
||||
} else {
|
||||
OkHttpClient()
|
||||
}
|
||||
cachedHttp = port to client
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a `blossom:<sha256>` (or `blossom://<sha256>`) blob from the user's Blossom servers
|
||||
* (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server
|
||||
* can never substitute the blob. Returns null for a malformed hash or if no server serves it.
|
||||
* (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a
|
||||
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
|
||||
* serves it.
|
||||
*/
|
||||
private fun fetchBlossom(url: String): NappletResource? {
|
||||
private fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResource? {
|
||||
val hash =
|
||||
url
|
||||
.removePrefix("blossom://")
|
||||
@@ -180,7 +174,6 @@ class NappletResourceFetcher(
|
||||
.getBlossomServersList()
|
||||
?.servers()
|
||||
.orEmpty()
|
||||
val client = client()
|
||||
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
|
||||
val bytes =
|
||||
runCatching {
|
||||
|
||||
+5
-1
@@ -40,7 +40,11 @@ class DualHttpClientManager(
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
onionCache: OnionLocationCache? = null,
|
||||
// Required (not nullable): every general-purpose HTTP client we mint must be
|
||||
// wired into the OnionLocationCache so the app's `.onion`-routing behavior
|
||||
// is uniform across image, upload, NIP-05, money, preview, and push roles.
|
||||
// See [OkHttpClientFactory] kdoc.
|
||||
onionCache: OnionLocationCache,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
|
||||
|
||||
|
||||
@@ -22,18 +22,31 @@ package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.os.Build
|
||||
|
||||
internal fun isEmulator(): Boolean =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.lowercase().contains("emulator") ||
|
||||
Build.MODEL.contains("google_sdk") ||
|
||||
Build.MODEL.lowercase().contains("droid4x") ||
|
||||
Build.MODEL.contains("Emulator") ||
|
||||
Build.MODEL.contains("Android SDK built for x86") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
||||
"google_sdk" == Build.PRODUCT ||
|
||||
Build.HARDWARE.contains("goldfish") ||
|
||||
Build.HARDWARE.contains("ranchu") ||
|
||||
Build.HARDWARE.contains("vbox86") ||
|
||||
Build.HARDWARE.contains("nox") ||
|
||||
Build.HARDWARE.contains("cuttlefish")
|
||||
// Kotlin types android.os.Build's static fields as non-null String, but the JVM stub
|
||||
// used in unit tests (testOptions.unitTests.isReturnDefaultValues = true) leaves them
|
||||
// null. A naive `.startsWith` would NPE there. Coalesce to empty so unit tests can
|
||||
// instantiate the OkHttp factories — production behavior is unchanged (real Android
|
||||
// always populates these).
|
||||
internal fun isEmulator(): Boolean {
|
||||
val fingerprint = Build.FINGERPRINT ?: ""
|
||||
val model = Build.MODEL ?: ""
|
||||
val manufacturer = Build.MANUFACTURER ?: ""
|
||||
val brand = Build.BRAND ?: ""
|
||||
val device = Build.DEVICE ?: ""
|
||||
val product = Build.PRODUCT ?: ""
|
||||
val hardware = Build.HARDWARE ?: ""
|
||||
return fingerprint.startsWith("generic") ||
|
||||
fingerprint.lowercase().contains("emulator") ||
|
||||
model.contains("google_sdk") ||
|
||||
model.lowercase().contains("droid4x") ||
|
||||
model.contains("Emulator") ||
|
||||
model.contains("Android SDK built for x86") ||
|
||||
manufacturer.contains("Genymotion") ||
|
||||
(brand.startsWith("generic") && device.startsWith("generic")) ||
|
||||
"google_sdk" == product ||
|
||||
hardware.contains("goldfish") ||
|
||||
hardware.contains("ranchu") ||
|
||||
hardware.contains("vbox86") ||
|
||||
hardware.contains("nox") ||
|
||||
hardware.contains("cuttlefish")
|
||||
}
|
||||
|
||||
+26
-3
@@ -32,6 +32,22 @@ import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Builder for the general-purpose (non-relay) OkHttp client used by image
|
||||
* downloads, NIP-96/Blossom uploads, NIP-05 lookups, LNURL/money, link
|
||||
* previews, push registration, etc. Each [DualHttpClientManager] holds one
|
||||
* of these and rebuilds clients on Tor proxy / mobile-data changes.
|
||||
*
|
||||
* [OnionLocationCache] is **required**: every OkHttp client minted by this
|
||||
* factory installs [OnionLocationInterceptor] on the no-proxy and Tor-proxied
|
||||
* variants so any HTTP/WebSocket response that carries an `Onion-Location`
|
||||
* header (NIP-11 docs, image hosts, blossom servers, NIP-96 endpoints, LNURL
|
||||
* providers, anything) populates the cache. Tor-proxied clients additionally
|
||||
* install [OnionUrlRewriteInterceptor] so subsequent requests are rerouted
|
||||
* to the cached `.onion` and avoid exit nodes entirely. Treating the cache as
|
||||
* required (not nullable) prevents a future call site from silently disabling
|
||||
* onion-routing for an entire HTTP role.
|
||||
*/
|
||||
class OkHttpClientFactory(
|
||||
keyCache: EncryptionKeyCache,
|
||||
val userAgent: String,
|
||||
@@ -43,7 +59,7 @@ class OkHttpClientFactory(
|
||||
* useful for tests or pre-configuration call sites.
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache? = null,
|
||||
private val onionCache: OnionLocationCache,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -92,7 +108,11 @@ class OkHttpClientFactory(
|
||||
}
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
.apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } }
|
||||
// Passively populates [onionCache] from any HTTP/WebSocket response
|
||||
// that carries an `Onion-Location` header. Application-scoped so the
|
||||
// cache key is always the original clearnet host (see kdoc on
|
||||
// [OnionLocationInterceptor]).
|
||||
.addInterceptor(OnionLocationInterceptor(onionCache))
|
||||
.build()
|
||||
|
||||
private var lastProxy: Proxy? = null
|
||||
@@ -109,7 +129,10 @@ class OkHttpClientFactory(
|
||||
return rootClient
|
||||
.newBuilder()
|
||||
.proxy(proxy)
|
||||
.apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } }
|
||||
// Only the Tor-routed variant rewrites outbound URLs to known
|
||||
// `.onion`s — clearnet clients must never try to resolve `.onion`
|
||||
// (DNS would fail, and we don't want fingerprintable lookups).
|
||||
.apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) }
|
||||
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* 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.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
/**
|
||||
* Pins the Onion-Location interceptor behavior the entire app's onion-routing
|
||||
* story relies on.
|
||||
*
|
||||
* Onion-Location is a free passive discovery mechanism: an `.onion`-equipped server
|
||||
* advertises its onion address via an HTTP response header, and the app caches the
|
||||
* mapping for later Tor-routed connections. The mechanism only works if **every**
|
||||
* OkHttp client built by the app installs the interceptors:
|
||||
*
|
||||
* - [OnionLocationInterceptor] on every client (no-proxy AND Tor-proxied) so we capture
|
||||
* the header from whichever client happened to make the request.
|
||||
* - [OnionUrlRewriteInterceptor] on Tor-proxied clients only (clearnet clients must never
|
||||
* try to resolve `.onion`).
|
||||
*
|
||||
* Wiring is enforced by [OkHttpClientFactory] and [OkHttpClientFactoryForRelays] taking
|
||||
* the cache as a **non-nullable** constructor parameter and unconditionally adding the
|
||||
* interceptors in their builder chain. Factory-instantiation in unit tests is blocked by
|
||||
* OkHttp 5's Android platform shim (it calls `android.util.Log.isLoggable`, which only
|
||||
* exists on a real device), so the wiring is pinned at the type level rather than via
|
||||
* reflective inspection of a built client. This suite covers the interceptors themselves
|
||||
* and the cache they share so any regression in their behavior — missing put, wrong
|
||||
* rewrite scheme, stale TTL, etc. — fails fast.
|
||||
*
|
||||
* The chain is a [Proxy] that only implements [Interceptor.Chain.request] and
|
||||
* [Interceptor.Chain.proceed]; same pattern as [LocalBlossomCacheRedirectInterceptorTest].
|
||||
*/
|
||||
class OnionInterceptorWiringTest {
|
||||
// --- OnionLocationInterceptor: passively captures the header ---
|
||||
|
||||
@Test
|
||||
fun locationInterceptor_recordsHeaderUnderClearnetHost() {
|
||||
val cache = OnionLocationCache()
|
||||
val interceptor = OnionLocationInterceptor(cache)
|
||||
|
||||
runWithResponse(
|
||||
interceptor = interceptor,
|
||||
url = "https://example.com/some/path",
|
||||
responseHeaders = mapOf("Onion-Location" to "http://abcdef.onion/"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Cache key must be the original clearnet host, not the .onion — otherwise " +
|
||||
"the rewriter on the next request looks up a key that was never written.",
|
||||
"http://abcdef.onion/",
|
||||
cache.get("example.com"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locationInterceptor_websocketUpgrade_alsoRecords() {
|
||||
// The Onion-Location header is captured from any response — including the
|
||||
// WebSocket 101 Switching Protocols upgrade handshake on a Nostr relay
|
||||
// connection. This is the primary discovery path for Tor-routed relays.
|
||||
// (OkHttp normalizes ws/wss to http/https on Request.url, so even though
|
||||
// the relay URL is `wss://`, the interceptor sees `https://`. The cache
|
||||
// entry is keyed by that normalized host, which is what the rewriter
|
||||
// later looks up on the next outbound `wss://` request.)
|
||||
val cache = OnionLocationCache()
|
||||
val interceptor = OnionLocationInterceptor(cache)
|
||||
|
||||
runWithResponse(
|
||||
interceptor = interceptor,
|
||||
url = "https://relay.example/",
|
||||
responseCode = 101,
|
||||
responseHeaders = mapOf("Onion-Location" to "http://relay.onion/"),
|
||||
)
|
||||
|
||||
assertEquals("http://relay.onion/", cache.get("relay.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locationInterceptor_noHeader_writesNothing() {
|
||||
val cache = OnionLocationCache()
|
||||
val interceptor = OnionLocationInterceptor(cache)
|
||||
|
||||
runWithResponse(
|
||||
interceptor = interceptor,
|
||||
url = "https://example.com/",
|
||||
responseHeaders = emptyMap(),
|
||||
)
|
||||
|
||||
assertNull(
|
||||
"A response with no Onion-Location must not pollute the cache.",
|
||||
cache.get("example.com"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- OnionUrlRewriteInterceptor: rewrites outbound URLs to known .onions ---
|
||||
|
||||
@Test
|
||||
fun rewriteInterceptor_unknownHost_passesThrough() {
|
||||
val cache = OnionLocationCache()
|
||||
val interceptor = OnionUrlRewriteInterceptor(cache)
|
||||
|
||||
val captured = captureRewrite(interceptor, "https://example.com/")
|
||||
|
||||
assertEquals(
|
||||
"Unknown hosts must reach the original URL untouched.",
|
||||
"https://example.com/",
|
||||
captured,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewriteInterceptor_https_to_httpsOnion_keepsHttps() {
|
||||
val cache = OnionLocationCache().apply { put("example.com", "https://abc.onion/") }
|
||||
val interceptor = OnionUrlRewriteInterceptor(cache)
|
||||
|
||||
val captured = captureRewrite(interceptor, "https://example.com/foo")
|
||||
val url = captured.toHttpUrl()
|
||||
|
||||
assertEquals("abc.onion", url.host)
|
||||
assertEquals("https", url.scheme)
|
||||
assertEquals("/foo", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewriteInterceptor_https_to_httpOnion_downgradesToHttp() {
|
||||
// An onion service advertising http:// is not a real downgrade: the Tor
|
||||
// hidden-service descriptor already provides authenticated end-to-end
|
||||
// encryption. We keep the request in the http protocol family.
|
||||
val cache = OnionLocationCache().apply { put("example.com", "http://abc.onion:8080/") }
|
||||
val interceptor = OnionUrlRewriteInterceptor(cache)
|
||||
|
||||
val url = captureRewrite(interceptor, "https://example.com/").toHttpUrl()
|
||||
|
||||
assertEquals("http", url.scheme)
|
||||
assertEquals(8080, url.port)
|
||||
}
|
||||
|
||||
// Note: OkHttp normalizes `ws/wss` URLs to `http/https` on `Request.url`
|
||||
// before any interceptor runs, so the `ws/wss` branches in
|
||||
// OnionUrlRewriteInterceptor.intercept() are defensive code only —
|
||||
// unreachable from a real OkHttp call site. Tests of them would have to
|
||||
// construct an HttpUrl with a ws/wss scheme, which the OkHttp 5 API
|
||||
// refuses to parse. The behavior we DO need to pin is that the http/https
|
||||
// path keeps the protocol family across the onion swap, which the
|
||||
// `rewriteInterceptor_https_to_*` cases above already cover.
|
||||
|
||||
@Test
|
||||
fun rewriteInterceptor_unparseableOnionUrl_passesThrough() {
|
||||
// Malformed cache entries must not break connectivity — fall back to original.
|
||||
val cache = OnionLocationCache().apply { put("example.com", "not a url") }
|
||||
val interceptor = OnionUrlRewriteInterceptor(cache)
|
||||
|
||||
val captured = captureRewrite(interceptor, "https://example.com/")
|
||||
assertEquals("https://example.com/", captured)
|
||||
}
|
||||
|
||||
// --- OnionLocationCache: TTL + shared identity ---
|
||||
|
||||
@Test
|
||||
fun cache_putGet_roundTripsValue() {
|
||||
val cache = OnionLocationCache()
|
||||
cache.put("example.com", "http://abc.onion/")
|
||||
assertEquals("http://abc.onion/", cache.get("example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cache_unknownHost_returnsNull() {
|
||||
val cache = OnionLocationCache()
|
||||
assertNull(cache.get("never-seen.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cache_isSharedAcrossInterceptorPair() {
|
||||
// Critical invariant: an entry the location interceptor writes must be
|
||||
// visible to the rewriter on the next request. Both interceptors close
|
||||
// over the SAME cache instance — nothing fancier than object identity.
|
||||
val cache = OnionLocationCache()
|
||||
val location = OnionLocationInterceptor(cache)
|
||||
val rewriter = OnionUrlRewriteInterceptor(cache)
|
||||
|
||||
// 1. First response advertises an onion.
|
||||
runWithResponse(
|
||||
interceptor = location,
|
||||
url = "https://example.com/",
|
||||
responseHeaders = mapOf("Onion-Location" to "http://abc.onion/"),
|
||||
)
|
||||
|
||||
// 2. A subsequent request to the same host hits the rewriter and goes to .onion.
|
||||
val capturedUrl = captureRewrite(rewriter, "https://example.com/api/v1").toHttpUrl()
|
||||
|
||||
assertEquals("abc.onion", capturedUrl.host)
|
||||
assertEquals(
|
||||
"Path must be preserved across the rewrite.",
|
||||
"/api/v1",
|
||||
capturedUrl.encodedPath,
|
||||
)
|
||||
assertNotEquals(
|
||||
"Host must have changed.",
|
||||
"example.com",
|
||||
capturedUrl.host,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Compile-time pin: the interceptors must remain OkHttp [Interceptor]s ---
|
||||
|
||||
@Test
|
||||
fun interceptorClasses_remainOkHttpInterceptors() {
|
||||
val cache = OnionLocationCache()
|
||||
|
||||
// Assigning to an `Interceptor` reference is the compile-time pin: a future
|
||||
// refactor that drops the interface from either class breaks this build,
|
||||
// because addInterceptor() in both factories takes Interceptor.
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val a: Interceptor = OnionLocationInterceptor(cache)
|
||||
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val b: Interceptor = OnionUrlRewriteInterceptor(cache)
|
||||
}
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
/**
|
||||
* Runs [interceptor] against a request to [url], having `proceed` return a synthetic
|
||||
* response with [responseCode] / [responseHeaders]. Returns the URL that
|
||||
* `proceed` was ultimately called with (so rewrite tests can read it).
|
||||
*/
|
||||
private fun runWithResponse(
|
||||
interceptor: Interceptor,
|
||||
url: String,
|
||||
responseCode: Int = 200,
|
||||
responseHeaders: Map<String, String> = emptyMap(),
|
||||
): String {
|
||||
val captured = mutableListOf<String>()
|
||||
val chain = fakeChain(url, captured, responseCode, responseHeaders)
|
||||
interceptor.intercept(chain).close()
|
||||
return captured.single()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for rewrite-interceptor tests: returns the URL that `proceed`
|
||||
* was called with (i.e. the rewritten URL, or the original if no rewrite).
|
||||
*/
|
||||
private fun captureRewrite(
|
||||
interceptor: Interceptor,
|
||||
url: String,
|
||||
): String = runWithResponse(interceptor, url)
|
||||
|
||||
/**
|
||||
* A dynamic-proxy chain that implements only [Interceptor.Chain.request] and
|
||||
* [Interceptor.Chain.proceed]. Used because OkHttp 5's `Chain` declares ~40
|
||||
* members (client-config getters and `withX` reconfigurers) that these tests
|
||||
* never exercise.
|
||||
*/
|
||||
private fun fakeChain(
|
||||
url: String,
|
||||
captured: MutableList<String>,
|
||||
responseCode: Int,
|
||||
responseHeaders: Map<String, String>,
|
||||
): Interceptor.Chain {
|
||||
val request = Request.Builder().url(url.toHttpUrl()).build()
|
||||
return Proxy.newProxyInstance(
|
||||
Interceptor.Chain::class.java.classLoader,
|
||||
arrayOf(Interceptor.Chain::class.java),
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"request" -> request
|
||||
"proceed" -> {
|
||||
val proceeded = args[0] as Request
|
||||
captured.add(proceeded.url.toString())
|
||||
val builder =
|
||||
Response
|
||||
.Builder()
|
||||
.request(proceeded)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(responseCode)
|
||||
.message(if (responseCode == 101) "Switching Protocols" else "OK")
|
||||
.body("".toResponseBody(null))
|
||||
responseHeaders.forEach { (k, v) -> builder.header(k, v) }
|
||||
builder.build()
|
||||
}
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as Interceptor.Chain
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -274,7 +274,7 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.ResourceBytes -> {
|
||||
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
|
||||
val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.")
|
||||
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.")
|
||||
NappletResponse.Bytes(fetched.bytes, fetched.contentType)
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -161,9 +161,16 @@ class NappletResource(
|
||||
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
|
||||
* the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an
|
||||
* unsupported scheme or a failed fetch.
|
||||
*
|
||||
* [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can
|
||||
* route the fetch the same way the applet's own page loads — through Tor or the open web — per that
|
||||
* applet's/site's network mode.
|
||||
*/
|
||||
fun interface NappletResourceGateway {
|
||||
suspend fun fetch(url: String): NappletResource?
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource?
|
||||
}
|
||||
|
||||
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */
|
||||
|
||||
Reference in New Issue
Block a user