diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt index 256ea565..7868e1ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt @@ -15,11 +15,14 @@ object AiturkConnectionRegistrar { const val FALLBACK_PC_NAME = "AITURK IDE" suspend fun register(device: AiturkPairedDevice) { - val baseUrl = device.endpoint.baseUrl + // The address the claim was sent to: the LAN `https://ip:port/` or the + // virtual tunnel host `https://.relay.aiturk.invalid/`. + val baseUrl = device.baseUrl val name = device.pcName.ifBlank { FALLBACK_PC_NAME } - // Trust first: every later request to this host:port is checked against the pin. - AiturkLanTrust.pin(device.endpoint.hostPort, device.fingerprint) + // Trust first: every later request to this host:port — over the LAN or + // through the tunnel — is checked against the pin, end to end. + AiturkLanTrust.pin(device.hostPort, device.fingerprint) withContext(Dispatchers.IO) { HermesWsClient.disconnect() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt index 18c58a48..00a576c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt @@ -44,6 +44,7 @@ object AiturkLanTrust { } private const val PREF_KEY = "aiturk_lan_pins" + private val IPV4_HOST_PORT = Regex("""\d{1,3}(\.\d{1,3}){3}:\d{1,5}""") private val pins = ConcurrentHashMap() @@ -71,6 +72,9 @@ object AiturkLanTrust { builder .sslSocketFactory(sslSocketFactory, trustManager) .hostnameVerifier(hostnameVerifier) + // A relay pairing lives on a virtual host: DNS, socket and proxy + // choice are special-cased for that host only (see AiturkTunnel). + .let(AiturkTunnel::applyTo) }.getOrDefault(builder) /** @@ -80,6 +84,7 @@ object AiturkLanTrust { fun pinnedClient( base: OkHttpClient, fingerprint: String, + tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector }, ): OkHttpClient { val lookup: (String) -> String? = { fingerprint } val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) } @@ -87,6 +92,7 @@ object AiturkLanTrust { .newBuilder() .sslSocketFactory(socketFactoryFor(manager), manager) .hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup)) + .let { AiturkTunnel.applyTo(it, tunnel) } .build() } @@ -95,6 +101,11 @@ object AiturkLanTrust { fingerprint: String, ) { require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" } + // Pins are for the IDE's own TLS proxy only: its LAN `ip:port`, or the + // virtual tunnel host. A real DNS name keeps the platform's checks. + require(IPV4_HOST_PORT.matches(hostPort) || isRelayHostPort(hostPort)) { + "Only an AITURK IDE address can be pinned" + } ensureLoaded() // Saving on top of an unread store would drop the pins already in it. check(loaded) { "Secure storage is not ready" } @@ -152,11 +163,18 @@ object AiturkLanTrust { return MessageDigest.isEqual(expected, actual) } + private fun isRelayHostPort(hostPort: String): Boolean = + hostPort.endsWith(":${AiturkRelay.PORT}") && AiturkRelay.isVirtualHost(hostPort.substringBeforeLast(':')) + + // Wrapped so that TLS over a tunnel stream is layered on a socket without a + // file descriptor; for every other socket the wrapper is a pass-through. private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory = - SSLContext - .getInstance("TLS") - .apply { init(null, arrayOf(manager), null) } - .socketFactory + AiturkTunnelSslSocketFactory( + SSLContext + .getInstance("TLS") + .apply { init(null, arrayOf(manager), null) } + .socketFactory, + ) private fun defaultTrustManager(): X509TrustManager { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt index 08beb38a..37645e38 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt @@ -2,13 +2,15 @@ package com.m57.hermescontrol.data.aiturk import com.m57.hermescontrol.data.remote.await import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull @@ -22,7 +24,18 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer +import java.io.IOException +import java.io.InterruptedIOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.PortUnreachableException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.security.cert.CertificateException +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException /** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */ enum class AiturkPairError { @@ -35,9 +48,33 @@ enum class AiturkPairError { /** Gateway unreachable or answered something unexpected. */ GATEWAY_UNAVAILABLE, - /** No candidate address answered `hello` with the pinned certificate in time. */ + /** No LAN candidate answered `hello` with the pinned certificate in time. */ PC_UNREACHABLE, + /** Tunnel closed with 4503: AITURK IDE is not running or Phone connection is off. */ + HOST_OFFLINE, + + /** Tunnel closed with 4504, or the computer stayed silent inside the tunnel. */ + HOST_TIMEOUT, + + /** Tunnel closed with 4429: too many streams for this relay. */ + RELAY_BUSY, + + /** Tunnel closed with 4404: the relay id no longer exists. */ + RELAY_UNKNOWN, + + /** turkservis.online could not be reached at all (no internet, DNS, timeout). */ + NO_INTERNET, + + /** Inside the tunnel the certificate did not match the pairing fingerprint. */ + TLS_PIN, + + /** The tunnel ended in a way the contract does not name. */ + RELAY_ERROR, + + /** Only LAN candidates were left and the local-network permission is denied. */ + LAN_PERMISSION, + /** `claim` 400 `invalid_request` (or 413). */ INVALID_REQUEST, @@ -54,34 +91,130 @@ enum class AiturkPairError { UNEXPECTED_RESPONSE, } +/** + * [baseUrl] is the address the `claim` was sent to (trailing slash): either the + * LAN `https:///` ([endpoint] set) or the virtual tunnel host + * `https://.relay.aiturk.invalid/` ([endpoint] null). In both cases the + * certificate is pinned to [fingerprint] under [hostPort]. + */ data class AiturkPairedDevice( - val endpoint: AiturkEndpoint, + val baseUrl: String, + val hostPort: String, + val endpoint: AiturkEndpoint?, val fingerprint: String, val token: String, val deviceId: String, val pcName: String, val version: String, -) +) { + val viaRelay: Boolean get() = endpoint == null +} sealed interface AiturkPairResult { data class Success( val value: T, ) : AiturkPairResult + /** + * [code] is a short diagnostic shown in small print (`relay:host_offline`, + * `tls:pin`, `lan:timeout`, `relay:network,lan:refused` …). It is assembled only from + * the fixed vocabulary in [AiturkDiag] plus an HTTP status number: never a + * token, pairing code, address or exception message. + */ data class Failure( val error: AiturkPairError, + val code: String = "", ) : AiturkPairResult } +/** Fixed vocabulary of the diagnostic codes. */ +object AiturkDiag { + const val TIMEOUT = "timeout" + const val DNS = "dns" + const val TLS = "tls" + const val REFUSED = "refused" + const val UNREACHABLE = "unreachable" + const val PERMISSION = "permission" + const val IO = "io" + const val BAD_RESPONSE = "bad_response" + const val NO_CANDIDATES = "no_candidates" + const val PAIRING_CLOSED = "pairing_closed" + const val PIN = "pin" + const val NETWORK = "network" + + private const val MAX_CAUSE_DEPTH = 6 + private val safe = Regex("[a-z]{1,8}:[a-z0-9_]{1,24}(,[a-z]{1,8}:[a-z0-9_]{1,24})*") + + /** Defence in depth for the UI: anything outside the vocabulary shape is not shown. */ + fun sanitize(code: String?): String = code?.takeIf { it.length <= MAX_CODE_LENGTH && safe.matches(it) }.orEmpty() + + /** + * Which kind of network failure [error] is. Looks at the exception TYPE and, + * for connect failures, at the errno name the platform puts in the message + * (`ECONNREFUSED`, `EHOSTUNREACH`, …). The message itself is never returned. + */ + fun networkKind(error: Throwable): String { + val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList() + val text = chain.joinToString(" ") { it.message.orEmpty() }.uppercase(Locale.ROOT) + + fun mentions(vararg needles: String) = needles.any { it in text } + return when { + chain.any { it is SSLException || it is CertificateException } -> TLS + + chain.any { it is UnknownHostException } -> DNS + + mentions("EPERM", "EACCES", "OPERATION NOT PERMITTED", "PERMISSION DENIED") -> PERMISSION + + chain.any { it is NoRouteToHostException || it is PortUnreachableException } -> UNREACHABLE + + mentions("EHOSTUNREACH", "ENETUNREACH", "NO ROUTE TO HOST", "NETWORK IS UNREACHABLE") -> UNREACHABLE + + chain.any { it is SocketTimeoutException } || mentions("ETIMEDOUT") -> TIMEOUT + + chain.any { it is ConnectException } -> REFUSED + + // OkHttp's callTimeout surfaces as InterruptedIOException("timeout"). + chain.any { it is InterruptedIOException } -> TIMEOUT + + else -> IO + } + } + + /** + * The same question for a request that went through the tunnel. The relay's + * own verdict (close code) wins; a certificate that does not match the + * pairing fingerprint is [PIN]; silence inside an open tunnel is [TIMEOUT]; + * everything else is the network between the phone and turkservis.online. + */ + fun tunnelKind(error: Throwable): String { + AiturkTunnelException.find(error)?.let { return it.kind } + val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList() + return when { + chain.any { it is CertificateException || it is javax.net.ssl.SSLPeerUnverifiedException } -> PIN + chain.any { it is InterruptedIOException } -> TIMEOUT + chain.any { it is SSLException } -> TLS + else -> NETWORK + } + } + + /** `relay:`, except the pin failure which the contract names `tls:pin`. */ + fun relayCode(kind: String): String = if (kind == PIN) "tls:$PIN" else "relay:$kind" + + private const val MAX_CODE_LENGTH = 96 +} + /** * Phone side of the AITURK IDE pairing handshake. * * Deliberately does not log: codes, tokens and bodies must never reach logcat. + * Contract: v1 (`aiturk-mobil-eslestirme-spec`) plus v3 (`aiturk-mobil-tunel-spec`). */ class AiturkPairingClient( private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(), private val baseClient: OkHttpClient = defaultBaseClient, private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS, + private val relayHelloTimeoutMs: Long = RELAY_HELLO_TIMEOUT_MS, + private val tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector }, ) { /** Code path: `resolve` the rendezvous record for a normalized code. */ suspend fun resolve(normalizedCode: String): AiturkPairResult = @@ -93,7 +226,7 @@ class AiturkPairingClient( val request = Request .Builder() - .url(gatewayBaseUrl.newBuilder().addPathSegments(RESOLVE_PATH).build()) + .url(under(gatewayBaseUrl, RESOLVE_PATH)) .post(body) .build() val client = @@ -105,47 +238,144 @@ class AiturkPairingClient( client.newCall(request).await().use { response -> when (response.code) { 200 -> parseOffer(readJson(response)) - 404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND) - 429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED) - else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE) + 404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND, "resolve:not_found") + 429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED, "resolve:rate_limited") + else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:http_${response.code}") } } - } catch (_: java.io.IOException) { - AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE) + } catch (_: IOException) { + AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:network") } } /** - * `hello` to every candidate in parallel (pinned to the offer's fingerprint), - * then exactly ONE `claim` to the first that answered. + * Tunnel first, LAN second, and exactly ONE `claim`: + * 1. [AiturkPairingOffer.relay] present → `hello` to the virtual tunnel host + * (TLS inside the tunnel, pinned to the offer's fingerprint). Answered → + * the single `claim` goes through the tunnel and the LAN candidates are + * never contacted. + * 2. Relay absent or failed → `hello` to every LAN candidate in parallel + * (same pin), single `claim` to the first answer. + * + * [lanAllowed] false (Android 17+ local-network permission denied) skips step + * 2 and reports `lan:permission`. When both steps fail the relay's failure is + * the user-facing error and both codes are kept (`relay:…,lan:…`). */ suspend fun pair( offer: AiturkPairingOffer, normalizedCode: String, deviceName: String, + lanAllowed: Boolean = true, ): AiturkPairResult = withContext(Dispatchers.IO) { - val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint) - val reachable = - firstReachable(pinned, offer.endpoints) - ?: return@withContext AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE) - if (!reachable.pairingOpen) { - // The computer says pairing is not open: a claim could only be - // answered with 410 pairing_closed, so do not spend an attempt. - return@withContext AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED) + var relayFailure: AiturkPairResult.Failure? = null + // The only trust on this client is the offer's fingerprint: on the + // LAN and inside the tunnel alike. + val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint, tunnel) + // Validated again here: an offer built by hand must not widen the target. + val relayId = offer.relay?.let(AiturkRelay::parse) + if (relayId != null) { + val relayBase = AiturkRelay.baseUrl(relayId).toHttpUrl() + when (val hello = relayHello(pinned, relayBase)) { + is Hello.Ok -> { + if (!hello.pairingOpen) { + return@withContext AiturkPairResult.Failure( + AiturkPairError.PAIRING_CLOSED, + "relay:${AiturkDiag.PAIRING_CLOSED}", + ) + } + return@withContext claim( + pinned, + relayBase, + AiturkRelay.hostPort(relayId), + null, + offer.fingerprint, + normalizedCode, + deviceName, + ) + } + + is Hello.Failed -> { + relayFailure = AiturkPairResult.Failure(relayError(hello.kind), AiturkDiag.relayCode(hello.kind)) + } + } } - claim(pinned, reachable.endpoint, offer.fingerprint, normalizedCode, deviceName) + + val lan = pairOverLan(pinned, offer, normalizedCode, deviceName, lanAllowed) + if (lan is AiturkPairResult.Failure && relayFailure != null && lan.code.startsWith("lan:")) { + // Nothing was claimed on the LAN either: the relay is the designed + // path, so its failure is what the user needs to hear about. + return@withContext AiturkPairResult.Failure(relayFailure.error, "${relayFailure.code},${lan.code}") + } + lan } - private data class Reachable( - val endpoint: AiturkEndpoint, - val pairingOpen: Boolean, - ) + private suspend fun pairOverLan( + pinned: OkHttpClient, + offer: AiturkPairingOffer, + normalizedCode: String, + deviceName: String, + lanAllowed: Boolean, + ): AiturkPairResult { + if (offer.endpoints.isEmpty()) { + return AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE, "lan:${AiturkDiag.NO_CANDIDATES}") + } + if (!lanAllowed) { + return AiturkPairResult.Failure(AiturkPairError.LAN_PERMISSION, "lan:${AiturkDiag.PERMISSION}") + } + val reachable = + when (val hello = firstReachable(pinned, offer.endpoints)) { + is Hello.Ok -> hello + is Hello.Failed -> return AiturkPairResult.Failure(lanError(hello.kind), "lan:${hello.kind}") + } + if (!reachable.pairingOpen) { + // The computer says pairing is not open: a claim could only be + // answered with 410 pairing_closed, so do not spend an attempt. + return AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED, "lan:${AiturkDiag.PAIRING_CLOSED}") + } + val endpoint = checkNotNull(reachable.endpoint) + return claim( + pinned, + endpoint.baseUrl.toHttpUrl(), + endpoint.hostPort, + endpoint, + offer.fingerprint, + normalizedCode, + deviceName, + ) + } + + private sealed interface Hello { + data class Ok( + val endpoint: AiturkEndpoint?, + val pairingOpen: Boolean, + ) : Hello + + /** [kind] is one of the [AiturkDiag] words or `http_`. */ + data class Failed( + val kind: String, + ) : Hello + } + + private suspend fun relayHello( + pinned: OkHttpClient, + relayBase: HttpUrl, + ): Hello { + val client = + pinned + .newBuilder() + .connectTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS) + .callTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS) + .build() + return withTimeoutOrNull(relayHelloTimeoutMs + HELLO_MARGIN_MS) { hello(client, relayBase, null) } + ?: Hello.Failed(AiturkDiag.TIMEOUT) + } private suspend fun firstReachable( pinned: OkHttpClient, endpoints: List, - ): Reachable? { + ): Hello { val helloClient = pinned .newBuilder() @@ -153,78 +383,128 @@ class AiturkPairingClient( .readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS) .callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS) .build() + val failures = CopyOnWriteArrayList() // Small margin over the per-call timeout so a slow candidate cannot hold the result. - return withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) { - channelFlow { - endpoints.forEach { endpoint -> - launch { hello(helloClient, endpoint)?.let { send(it) } } + val winner = + withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) { + coroutineScope { + val answers = Channel(endpoints.size) + endpoints.forEach { endpoint -> + launch { answers.send(hello(helloClient, endpoint.baseUrl.toHttpUrl(), endpoint)) } + } + var ok: Hello.Ok? = null + var received = 0 + while (ok == null && received < endpoints.size) { + when (val answer = answers.receive()) { + is Hello.Ok -> ok = answer + is Hello.Failed -> failures += answer.kind + } + received++ + } + coroutineContext.cancelChildren() + ok } - }.firstOrNull() - } + } + if (winner != null) return winner + // Candidates still silent when the window closed count as timeouts. + if (failures.size < endpoints.size) failures += AiturkDiag.TIMEOUT + return Hello.Failed(mostTelling(failures)) } private suspend fun hello( client: OkHttpClient, - endpoint: AiturkEndpoint, - ): Reachable? = + base: HttpUrl, + endpoint: AiturkEndpoint?, + ): Hello = try { - val request = Request.Builder().url(lanUrl(endpoint, HELLO_PATH)).get().build() + val request = Request.Builder().url(under(base, HELLO_PATH)).get().build() client.newCall(request).await().use { response -> - if (response.code != 200) return null - val json = readJson(response) ?: return null - if (json.string("app") != IDE_APP_ID) return null - Reachable(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false) + val json = readJson(response) + when { + response.code != 200 -> { + Hello.Failed("http_${response.code}") + } + + json?.string("app") != IDE_APP_ID -> { + Hello.Failed(AiturkDiag.BAD_RESPONSE) + } + + else -> { + Hello.Ok(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false) + } + } } - } catch (_: java.io.IOException) { - null + } catch (e: IOException) { + Hello.Failed(if (endpoint == null) AiturkDiag.tunnelKind(e) else AiturkDiag.networkKind(e)) } private suspend fun claim( - pinned: OkHttpClient, - endpoint: AiturkEndpoint, + client: OkHttpClient, + base: HttpUrl, + hostPort: String, + endpoint: AiturkEndpoint?, fingerprint: String, normalizedCode: String, deviceName: String, ): AiturkPairResult { + val viaRelay = endpoint == null val body = buildJsonObject { put("code", normalizedCode) put("device_name", sanitizeDeviceName(deviceName)) }.toString().toRequestBody(JSON) - val request = Request.Builder().url(lanUrl(endpoint, CLAIM_PATH)).post(body).build() + val request = Request.Builder().url(under(base, CLAIM_PATH)).post(body).build() // The code is single-use and wrong attempts are counted: never let the // HTTP stack silently replay this POST. - val client = - pinned + val claimClient = + client .newBuilder() .retryOnConnectionFailure(false) .callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS) .build() return try { - client.newCall(request).await().use { response -> + claimClient.newCall(request).await().use { response -> val json = readJson(response) - if (response.code == 200) { - parseClaim(json, endpoint, fingerprint) - } else { - AiturkPairResult.Failure(claimError(response.code, json?.string("error"))) + val errorName = json?.string("error") + when { + response.code == 200 -> { + parseClaim(json, base, hostPort, endpoint, fingerprint) + } + + else -> { + val error = claimError(response.code, errorName) + AiturkPairResult.Failure(error, "claim:${claimCode(error, response.code)}") + } } } - } catch (_: java.io.IOException) { - AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE) + } catch (e: IOException) { + // The claim may or may not have reached the computer: it is never + // retried and never re-sent over the other path. + if (viaRelay) { + val kind = AiturkDiag.tunnelKind(e) + AiturkPairResult.Failure(relayError(kind), "claim:relay_$kind") + } else { + val kind = AiturkDiag.networkKind(e) + AiturkPairResult.Failure(lanError(kind), "claim:lan_$kind") + } } } private fun parseClaim( json: JsonObject?, - endpoint: AiturkEndpoint, + base: HttpUrl, + hostPort: String, + endpoint: AiturkEndpoint?, fingerprint: String, ): AiturkPairResult { val token = json?.string("token") if (token == null || !TOKEN_PATTERN.matches(token)) { - return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE) + return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "claim:${AiturkDiag.BAD_RESPONSE}") } return AiturkPairResult.Success( AiturkPairedDevice( + baseUrl = base.toString(), + hostPort = hostPort, endpoint = endpoint, fingerprint = fingerprint, token = token, @@ -236,30 +516,34 @@ class AiturkPairingClient( } private fun parseOffer(json: JsonObject?): AiturkPairResult { + val bad = AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:${AiturkDiag.BAD_RESPONSE}") val fingerprint = json?.string("fp") - val urls = (json?.get("urls") as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } - if (fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint) || urls == null) { - return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE) - } - // The rendezvous record is untrusted input: only private IPv4 targets, 1..4. + if (json == null || fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint)) return bad + // The rendezvous record is untrusted input: only private IPv4 targets + // (0..4) and, optionally, a lowercase UUIDv4 relay id. + val urls = + when (val raw = json["urls"]) { + null, is JsonNull -> emptyList() + is JsonArray -> raw.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + else -> return bad + } val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) } - if (endpoints.isEmpty() || endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) { - return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE) - } - return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint)) + if (endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) return bad + val relay = + when (val raw = json["relay"]) { + null, is JsonNull -> null + is JsonPrimitive -> raw.takeIf { it.isString }?.content?.let(AiturkRelay::parse) ?: return bad + else -> return bad + } + if (endpoints.isEmpty() && relay == null) return bad + return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint, relay)) } - private fun lanUrl( - endpoint: AiturkEndpoint, + /** [path] under [base], keeping whatever path prefix the base carries (the relay's). */ + private fun under( + base: HttpUrl, path: String, - ): HttpUrl = - HttpUrl - .Builder() - .scheme("https") - .host(endpoint.host) - .port(endpoint.port) - .addPathSegments(path) - .build() + ): HttpUrl = base.newBuilder().addPathSegments(path).build() private fun readJson(response: Response): JsonObject? = runCatching { @@ -277,6 +561,7 @@ class AiturkPairingClient( companion object { const val DEFAULT_GATEWAY = "https://turkservis.online/" const val HELLO_TIMEOUT_MS = 2_500L + const val RELAY_HELLO_TIMEOUT_MS = 10_000L const val MAX_NAME_LENGTH = 60 private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve" private const val HELLO_PATH = "aiturk/pair/hello" @@ -289,6 +574,16 @@ class AiturkPairingClient( private val JSON = "application/json; charset=utf-8".toMediaType() private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$") + private val LAN_KIND_PRIORITY = + listOf( + AiturkDiag.PERMISSION, + AiturkDiag.TLS, + AiturkDiag.BAD_RESPONSE, + AiturkDiag.REFUSED, + AiturkDiag.UNREACHABLE, + AiturkDiag.TIMEOUT, + ) + // A bare client on purpose: no cookie jar, no user-configured custom // headers and no Authorization stamping reach the gateway or `claim`. private val defaultBaseClient: OkHttpClient by lazy { @@ -309,22 +604,70 @@ class AiturkPairingClient( .take(MAX_NAME_LENGTH) .ifBlank { "Android" } + /** Tunnel `hello`/`claim` failure kind → what the user is told (contract v3 §3). */ + internal fun relayError(kind: String): AiturkPairError = + when (kind) { + AiturkTunnelException.HOST_OFFLINE -> AiturkPairError.HOST_OFFLINE + AiturkTunnelException.UNKNOWN -> AiturkPairError.RELAY_UNKNOWN + AiturkTunnelException.BUSY -> AiturkPairError.RELAY_BUSY + AiturkTunnelException.TIMEOUT -> AiturkPairError.HOST_TIMEOUT + AiturkTunnelException.NETWORK -> AiturkPairError.NO_INTERNET + AiturkDiag.PIN -> AiturkPairError.TLS_PIN + else -> AiturkPairError.RELAY_ERROR + } + + /** LAN failure kind → what the user is told. */ + internal fun lanError(kind: String): AiturkPairError = + if (kind == AiturkDiag.PERMISSION) AiturkPairError.LAN_PERMISSION else AiturkPairError.PC_UNREACHABLE + + /** Of several failed LAN candidates, the one that says most about the cause. */ + internal fun mostTelling(kinds: List): String = + LAN_KIND_PRIORITY.firstOrNull { it in kinds } + ?: kinds.firstOrNull { it.startsWith("http_") } + ?: kinds.firstOrNull() + ?: AiturkDiag.TIMEOUT + + private fun claimCode( + error: AiturkPairError, + httpCode: Int, + ): String = + when (error) { + AiturkPairError.INVALID_REQUEST -> "invalid_request" + AiturkPairError.WRONG_CODE -> "wrong_code" + AiturkPairError.PAIRING_CLOSED -> "pairing_closed" + AiturkPairError.PAIRING_LOCKED -> "pairing_locked" + else -> "http_$httpCode" + } + internal fun claimError( httpCode: Int, errorName: String?, ): AiturkPairError = when (errorName) { - "invalid_request" -> AiturkPairError.INVALID_REQUEST - "wrong_code" -> AiturkPairError.WRONG_CODE - "pairing_closed" -> AiturkPairError.PAIRING_CLOSED - "pairing_locked" -> AiturkPairError.PAIRING_LOCKED - else -> + "invalid_request" -> { + AiturkPairError.INVALID_REQUEST + } + + "wrong_code" -> { + AiturkPairError.WRONG_CODE + } + + "pairing_closed" -> { + AiturkPairError.PAIRING_CLOSED + } + + "pairing_locked" -> { + AiturkPairError.PAIRING_LOCKED + } + + else -> { when (httpCode) { 400, 413 -> AiturkPairError.INVALID_REQUEST 401 -> AiturkPairError.WRONG_CODE 410 -> AiturkPairError.PAIRING_CLOSED else -> AiturkPairError.UNEXPECTED_RESPONSE } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt new file mode 100644 index 00000000..936e2ac0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt @@ -0,0 +1,714 @@ +package com.m57.hermescontrol.data.aiturk + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.Socket +import java.net.SocketAddress +import java.net.SocketException +import java.net.SocketImpl +import java.net.SocketTimeoutException +import java.net.URI +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import javax.net.SocketFactory +import javax.net.ssl.SSLSocketFactory + +/** + * End-to-end encrypted tunnel to a paired AITURK IDE through turkservis.online + * (contract v3, `aiturk-mobil-tunel-spec`). + * + * The relay only carries bytes. A paired relay connection has the virtual base + * address `https://.relay.aiturk.invalid` (no path prefix). For such a + * host — and for no other — the shared OkHttp client: + * - resolves the name to a fixed placeholder address ([AiturkTunnelDns]); + * - opens `wss://turkservis.online/v1/aiturk/mobile-relay/t/` instead + * of a TCP connection and exposes it as a socket ([AiturkTunnelSocket]); + * - runs TLS ON TOP of that socket, pinned to the IDE's certificate + * fingerprint ([AiturkLanTrust]). turkservis.online and anything in front of + * it see ciphertext only: no device token, path, chat text or file. + */ +object AiturkTunnel { + const val DEFAULT_RELAY_WS = "wss://turkservis.online/v1/aiturk/mobile-relay/t/" + const val MAX_FRAME_BYTES = 64 * 1024 + + /** How the byte stream is opened. Replaced by JVM tests with a local fake relay. */ + @Volatile + internal var connector: AiturkTunnelConnector = WebSocketTunnelConnector() + + /** + * The single hook for an OkHttp builder. Every host that is not a virtual + * relay host keeps the platform's DNS, plain TCP sockets and proxy choice. + */ + fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder = applyTo(builder) { connector } + + internal fun applyTo( + builder: OkHttpClient.Builder, + connector: () -> AiturkTunnelConnector, + ): OkHttpClient.Builder = + builder + .dns(AiturkTunnelDns()) + .socketFactory(AiturkTunnelSocketFactory(connector)) + .proxySelector(AiturkTunnelProxySelector()) +} + +/** + * Why a tunnel stream could not be opened or ended. [kind] is one of the fixed + * words below (it becomes the `relay:` diagnostic code). + */ +class AiturkTunnelException( + val kind: String, + val closeCode: Int? = null, + cause: Throwable? = null, +) : IOException("AITURK tunnel: $kind", cause) { + companion object { + const val HOST_OFFLINE = "host_offline" + const val UNKNOWN = "unknown" + const val BUSY = "busy" + const val TIMEOUT = "timeout" + const val OVERFLOW = "overflow" + const val NETWORK = "network" + const val REJECTED = "rejected" + const val CLOSED = "closed" + + /** Relay close code → kind; null for a normal end of stream. */ + fun kindForCloseCode(code: Int): String? = + when (code) { + 1000 -> null + 4503 -> HOST_OFFLINE + 4404 -> UNKNOWN + 4429 -> BUSY + 4504 -> TIMEOUT + 4408 -> OVERFLOW + 4400, 4403 -> REJECTED + else -> CLOSED + } + + fun find(error: Throwable?): AiturkTunnelException? = + generateSequence(error) { it.cause.takeIf { cause -> cause !== it } } + .take(MAX_CAUSE_DEPTH) + .filterIsInstance() + .firstOrNull() + + private const val MAX_CAUSE_DEPTH = 8 + } +} + +/** An open byte stream to the IDE's TLS proxy. Blocking, like a socket. */ +interface AiturkTunnelChannel { + val input: InputStream + val output: OutputStream + var soTimeoutMs: Int + val isClosed: Boolean + + fun close() +} + +fun interface AiturkTunnelConnector { + /** Blocks until the stream is open; throws [AiturkTunnelException] otherwise. */ + @Throws(IOException::class) + fun open( + relayId: String, + timeoutMs: Int, + ): AiturkTunnelChannel +} + +/** `*.relay.aiturk.invalid` → placeholder address (hostname kept); everything else → [delegate]. */ +class AiturkTunnelDns( + private val delegate: Dns = Dns.SYSTEM, +) : Dns { + override fun lookup(hostname: String): List = + if (AiturkRelay.isVirtualHost(hostname)) { + listOf(InetAddress.getByAddress(hostname, PLACEHOLDER)) + } else { + delegate.lookup(hostname) + } + + companion object { + // TEST-NET-1 (RFC 5737): never routed, and never dialled by the tunnel socket. + internal val PLACEHOLDER = byteArrayOf(192.toByte(), 0, 2, 1) + } +} + +/** Virtual relay hosts are never sent through a proxy; other hosts use the system selector. */ +class AiturkTunnelProxySelector : ProxySelector() { + override fun select(uri: URI?): List { + if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return listOf(Proxy.NO_PROXY) + return runCatching { ProxySelector.getDefault()?.select(uri) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: listOf(Proxy.NO_PROXY) + } + + override fun connectFailed( + uri: URI?, + sa: SocketAddress?, + ioe: IOException?, + ) { + if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return + runCatching { ProxySelector.getDefault()?.connectFailed(uri, sa, ioe) } + } +} + +/** + * OkHttp asks for an unconnected socket and only then names the peer, so the + * socket decides in `connect()`: a virtual relay host becomes a tunnel stream, + * any other address is an ordinary TCP connection made by [java.net.Socket] + * itself. + */ +class AiturkTunnelSocketFactory internal constructor( + private val connector: () -> AiturkTunnelConnector, +) : SocketFactory() { + override fun createSocket(): Socket = AiturkTunnelSocket(connector) + + override fun createSocket( + host: String?, + port: Int, + ): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) } + + override fun createSocket( + host: String?, + port: Int, + localHost: InetAddress?, + localPort: Int, + ): Socket = createSocket(host, port) + + override fun createSocket( + host: InetAddress?, + port: Int, + ): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) } + + override fun createSocket( + address: InetAddress?, + port: Int, + localAddress: InetAddress?, + localPort: Int, + ): Socket = createSocket(address, port) +} + +/** A plain TCP socket unless it is connected to a virtual relay host. */ +class AiturkTunnelSocket internal constructor( + private val connector: () -> AiturkTunnelConnector, +) : Socket() { + @Volatile + internal var channel: AiturkTunnelChannel? = null + private set + + @Volatile + internal var tunnelPeer: InetSocketAddress? = null + private set + + @Volatile + private var requestedSoTimeout = 0 + + override fun connect(endpoint: SocketAddress?) = connect(endpoint, 0) + + override fun connect( + endpoint: SocketAddress?, + timeout: Int, + ) { + val peer = endpoint as? InetSocketAddress + val relayId = peer?.hostString?.let(AiturkRelay::relayIdOfHost) + if (peer == null || relayId == null) { + super.connect(endpoint, timeout) + return + } + if (channel != null) throw SocketException("Already connected") + val opened = connector().open(relayId, if (timeout > 0) timeout else DEFAULT_CONNECT_TIMEOUT_MS) + opened.soTimeoutMs = requestedSoTimeout + tunnelPeer = peer + channel = opened + } + + override fun getInputStream(): InputStream = channel?.input ?: super.getInputStream() + + override fun getOutputStream(): OutputStream = channel?.output ?: super.getOutputStream() + + override fun setSoTimeout(timeout: Int) { + requestedSoTimeout = timeout + val tunnel = channel + if (tunnel != null) tunnel.soTimeoutMs = timeout else super.setSoTimeout(timeout) + } + + override fun getSoTimeout(): Int = channel?.soTimeoutMs ?: super.getSoTimeout() + + override fun setTcpNoDelay(on: Boolean) { + if (channel == null) super.setTcpNoDelay(on) + } + + override fun setKeepAlive(on: Boolean) { + if (channel == null) super.setKeepAlive(on) + } + + override fun isConnected(): Boolean = channel != null || super.isConnected() + + override fun isClosed(): Boolean = channel?.isClosed ?: super.isClosed() + + override fun isInputShutdown(): Boolean = if (channel != null) false else super.isInputShutdown() + + override fun isOutputShutdown(): Boolean = if (channel != null) false else super.isOutputShutdown() + + override fun getInetAddress(): InetAddress? = tunnelPeer?.address ?: super.getInetAddress() + + override fun getPort(): Int = tunnelPeer?.port ?: super.getPort() + + override fun getRemoteSocketAddress(): SocketAddress? = tunnelPeer ?: super.getRemoteSocketAddress() + + override fun shutdownInput() { + if (channel == null) super.shutdownInput() + } + + override fun shutdownOutput() { + if (channel == null) super.shutdownOutput() + } + + override fun close() { + channel?.close() + super.close() + } + + private companion object { + const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000 + } +} + +/** + * The view of a tunnel that is handed to the TLS layer: a socket WITHOUT a + * platform socket implementation (and so without a file descriptor). Android's + * TLS provider then takes its stream-based path and reads/writes through + * [getInputStream] / [getOutputStream] instead of a native descriptor. + */ +internal class AiturkTunnelStreamSocket( + private val owner: AiturkTunnelSocket, +) : Socket(null as SocketImpl?) { + private val tunnel: AiturkTunnelChannel get() = owner.channel ?: throw SocketException("Socket is not connected") + + override fun getInputStream(): InputStream = tunnel.input + + override fun getOutputStream(): OutputStream = tunnel.output + + override fun setSoTimeout(timeout: Int) = owner.setSoTimeout(timeout) + + override fun getSoTimeout(): Int = owner.soTimeout + + override fun isConnected(): Boolean = true + + override fun isBound(): Boolean = true + + override fun isClosed(): Boolean = owner.isClosed + + override fun isInputShutdown(): Boolean = false + + override fun isOutputShutdown(): Boolean = false + + override fun getInetAddress(): InetAddress? = owner.inetAddress + + override fun getPort(): Int = owner.port + + override fun getRemoteSocketAddress(): SocketAddress? = owner.remoteSocketAddress + + override fun getLocalAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0)) + + override fun getLocalPort(): Int = 0 + + override fun getLocalSocketAddress(): SocketAddress = InetSocketAddress(localAddress, 0) + + override fun setTcpNoDelay(on: Boolean) = Unit + + override fun getTcpNoDelay(): Boolean = true + + override fun setKeepAlive(on: Boolean) = Unit + + override fun getKeepAlive(): Boolean = false + + override fun setSoLinger( + on: Boolean, + linger: Int, + ) = Unit + + override fun getSoLinger(): Int = -1 + + override fun setSendBufferSize(size: Int) = Unit + + override fun getSendBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES + + override fun setReceiveBufferSize(size: Int) = Unit + + override fun getReceiveBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES + + override fun shutdownInput() = Unit + + override fun shutdownOutput() = Unit + + override fun close() = owner.close() + + override fun toString(): String = "AiturkTunnelStreamSocket" +} + +/** + * Wraps a TLS socket factory so that TLS over a tunnel is layered on the + * descriptor-less [AiturkTunnelStreamSocket]. Every other socket is passed to + * [delegate] untouched. + */ +internal class AiturkTunnelSslSocketFactory( + private val delegate: SSLSocketFactory, +) : SSLSocketFactory() { + override fun createSocket( + s: Socket?, + host: String?, + port: Int, + autoClose: Boolean, + ): Socket = + if (s is AiturkTunnelSocket && s.channel != null) { + // Closing the TLS socket must always close the tunnel stream. + delegate.createSocket(AiturkTunnelStreamSocket(s), host, port, true) + } else { + delegate.createSocket(s, host, port, autoClose) + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(): Socket = delegate.createSocket() + + override fun createSocket( + host: String?, + port: Int, + ): Socket = delegate.createSocket(host, port) + + override fun createSocket( + host: String?, + port: Int, + localHost: InetAddress?, + localPort: Int, + ): Socket = delegate.createSocket(host, port, localHost, localPort) + + override fun createSocket( + host: InetAddress?, + port: Int, + ): Socket = delegate.createSocket(host, port) + + override fun createSocket( + address: InetAddress?, + port: Int, + localAddress: InetAddress?, + localPort: Int, + ): Socket = delegate.createSocket(address, port, localAddress, localPort) +} + +/** + * Opens the stream as a WebSocket to the relay with a separate, bare OkHttp + * client: platform TLS, no cookie jar, no custom headers, no `Origin`. + * Binary frames are raw bytes in both directions. + */ +class WebSocketTunnelConnector( + private val relayWsBase: String = AiturkTunnel.DEFAULT_RELAY_WS, + client: OkHttpClient? = null, +) : AiturkTunnelConnector { + private val client: OkHttpClient by lazy { + client ?: OkHttpClient + .Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(PING_SECONDS, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .build() + } + + override fun open( + relayId: String, + timeoutMs: Int, + ): AiturkTunnelChannel { + // Only a validated relay id is ever placed in the URL. + val id = AiturkRelay.parse(relayId) ?: throw AiturkTunnelException(AiturkTunnelException.UNKNOWN) + val channel = WebSocketTunnelChannel() + val socket = client.newWebSocket(Request.Builder().url(relayWsBase + id).build(), channel.listener) + channel.attach(socket) + val opened = + try { + channel.awaitOpen(timeoutMs.toLong()) + } catch (e: InterruptedException) { + socket.cancel() + Thread.currentThread().interrupt() + throw AiturkTunnelException(AiturkTunnelException.NETWORK, cause = e) + } + if (!opened) { + socket.cancel() + throw channel.failure ?: AiturkTunnelException(AiturkTunnelException.NETWORK) + } + return channel + } + + private companion object { + const val PING_SECONDS = 25L + } +} + +internal class WebSocketTunnelChannel : AiturkTunnelChannel { + private val incoming = LinkedBlockingQueue() + private val openLatch = CountDownLatch(1) + private val flow = Object() + + @Volatile + private var socket: WebSocket? = null + + @Volatile + private var isOpen = false + + @Volatile + private var ended = false + + @Volatile + private var closedLocally = false + + @Volatile + internal var failure: AiturkTunnelException? = null + private set + + private var queuedBytes = 0L + + @Volatile + override var soTimeoutMs: Int = 0 + + override val isClosed: Boolean get() = closedLocally + + fun attach(webSocket: WebSocket) { + socket = webSocket + } + + /** True when the relay accepted the stream; false when it failed or timed out. */ + fun awaitOpen(timeoutMs: Long): Boolean = openLatch.await(timeoutMs, TimeUnit.MILLISECONDS) && isOpen + + val listener: WebSocketListener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + isOpen = true + openLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ) { + if (bytes.size == 0) return + synchronized(flow) { + // Applies back-pressure to the relay connection instead of + // buffering without bound when the reader is slow. + while (queuedBytes > MAX_BUFFERED_BYTES && !ended && !closedLocally) flow.wait(FLOW_WAIT_MS) + queuedBytes += bytes.size + } + incoming.put(bytes) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + // The stream is binary only. + end(AiturkTunnelException(AiturkTunnelException.REJECTED)) + webSocket.cancel() + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) }) + webSocket.close(NORMAL_CLOSE, null) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) = end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) }) + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + // An HTTP answer instead of the upgrade means the relay refused + // the stream; anything else is the network. + val kind = if (response != null) AiturkTunnelException.REJECTED else AiturkTunnelException.NETWORK + end(AiturkTunnelException(kind, cause = t)) + } + } + + private fun end(error: AiturkTunnelException?) { + synchronized(flow) { + if (ended) return + if (error != null && !closedLocally) failure = error + ended = true + flow.notifyAll() + } + incoming.offer(END) + openLatch.countDown() + } + + override val input: InputStream = + object : InputStream() { + private var current: ByteString? = null + private var position = 0 + + override fun read(): Int { + val one = ByteArray(1) + return if (read(one, 0, 1) == -1) -1 else one[0].toInt() and BYTE_MASK + } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + if (len == 0) return 0 + var chunk = current + if (chunk == null) { + chunk = next() ?: return -1 + current = chunk + position = 0 + } + val count = minOf(len, chunk.size - position) + chunk.copyInto(position, b, off, count) + position += count + if (position == chunk.size) current = null + synchronized(flow) { + queuedBytes -= count + flow.notifyAll() + } + return count + } + + override fun available(): Int = current?.let { it.size - position } ?: 0 + + override fun close() = this@WebSocketTunnelChannel.close() + + /** The next chunk, or null at a clean end of stream. */ + private fun next(): ByteString? { + if (closedLocally) throw SocketException("Socket closed") + val timeout = soTimeoutMs + val chunk = + try { + if (timeout > 0) incoming.poll(timeout.toLong(), TimeUnit.MILLISECONDS) else incoming.take() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw java.io.InterruptedIOException("interrupted") + } ?: throw SocketTimeoutException("Read timed out") + if (chunk !== END) return chunk + incoming.offer(END) + if (closedLocally) throw SocketException("Socket closed") + failure?.let { throw AiturkTunnelException(it.kind, it.closeCode, it) } + return null + } + } + + override val output: OutputStream = + object : OutputStream() { + override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1) + + override fun write( + b: ByteArray, + off: Int, + len: Int, + ) { + var offset = off + var remaining = len + while (remaining > 0) { + val count = minOf(remaining, AiturkTunnel.MAX_FRAME_BYTES) + send(b.toByteString(offset, count)) + offset += count + remaining -= count + } + } + + override fun close() = this@WebSocketTunnelChannel.close() + } + + private fun send(frame: ByteString) { + val webSocket = socket ?: throw SocketException("Socket is not connected") + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WRITE_STALL_MS) + // Keep the outbound queue small: the relay drops a stream whose + // pending data grows past its limit. + while (webSocket.queueSize() > MAX_PENDING_WRITE_BYTES) { + checkWritable() + if (System.nanoTime() > deadline) throw SocketTimeoutException("Write timed out") + try { + Thread.sleep(WRITE_POLL_MS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw java.io.InterruptedIOException("interrupted") + } + } + checkWritable() + if (!webSocket.send(frame)) { + checkWritable() + throw AiturkTunnelException(AiturkTunnelException.CLOSED) + } + } + + private fun checkWritable() { + if (closedLocally) throw SocketException("Socket closed") + if (ended) throw failure?.let { AiturkTunnelException(it.kind, it.closeCode, it) } ?: SocketException("Connection closed by peer") + } + + override fun close() { + synchronized(flow) { + if (closedLocally) return + closedLocally = true + flow.notifyAll() + } + val webSocket = socket + if (webSocket != null && !webSocket.close(NORMAL_CLOSE, null)) webSocket.cancel() + incoming.offer(END) + openLatch.countDown() + } + + private companion object { + val END: ByteString = ByteString.of(0) + const val NORMAL_CLOSE = 1000 + const val BYTE_MASK = 0xFF + const val MAX_BUFFERED_BYTES = 1L * 1024 * 1024 + const val MAX_PENDING_WRITE_BYTES = 256L * 1024 + const val FLOW_WAIT_MS = 50L + const val WRITE_POLL_MS = 2L + const val WRITE_STALL_MS = 30_000L + } +} + +/** + * What the tunnel last said about the computer behind a paired connection, for + * the chat's connection banner: "reconnecting…" alone does not tell the user + * that AITURK IDE is closed. + */ +object AiturkRelayStatus { + private val _hostOffline = MutableStateFlow(false) + val hostOffline: StateFlow = _hostOffline.asStateFlow() + + /** [error] is what ended (or prevented) the app's WebSocket; null clears the note. */ + fun onSocketEnded(error: Throwable?) { + _hostOffline.value = AiturkTunnelException.find(error)?.kind == AiturkTunnelException.HOST_OFFLINE + } + + fun onSocketOpened() { + _hostOffline.value = false + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt index e3c7c239..3e43602a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt @@ -106,8 +106,24 @@ fun AiturkPairScreen( LaunchedEffect(state.lanPermissionNeeded) { if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) } - LaunchedEffect(state.connectionSuccess) { - if (state.connectionSuccess) onConnected() + // The connection is already saved; the dialog only shows the security code + // (the same 8 characters AITURK IDE shows under the QR code) before leaving. + if (state.connectionSuccess) { + val securityCode = state.securityCode + if (securityCode == null) { + LaunchedEffect(Unit) { onConnected() } + } else { + AlertDialog( + onDismissRequest = onConnected, + title = { Text(stringResource(R.string.aiturk_pair_security_title)) }, + text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) }, + confirmButton = { + TextButton(onClick = onConnected) { + Text(stringResource(R.string.aiturk_pair_security_ok)) + } + }, + ) + } } val deepLink by AiturkPairDeepLink.pending.collectAsState() LaunchedEffect(deepLink) { @@ -256,6 +272,15 @@ fun AiturkPairScreen( style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, ) + // Diagnostic code for support: fixed words only, never a token, code or address. + if (state.errorCode.isNotEmpty()) { + Text( + text = stringResource(R.string.aiturk_pair_error_code, state.errorCode), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + textAlign = TextAlign.Center, + ) + } } TextButton(onClick = onBack, enabled = !state.busy) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt index 65b8701d..d39f1826 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModel.kt @@ -5,12 +5,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.R import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar +import com.m57.hermescontrol.data.aiturk.AiturkDiag import com.m57.hermescontrol.data.aiturk.AiturkPairError import com.m57.hermescontrol.data.aiturk.AiturkPairResult import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice import com.m57.hermescontrol.data.aiturk.AiturkPairing import com.m57.hermescontrol.data.aiturk.AiturkPairingClient -import com.m57.hermescontrol.data.aiturk.AiturkPairingOffer import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload import com.m57.hermescontrol.data.aiturk.AiturkPayloadError import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult @@ -27,10 +27,14 @@ data class AiturkPairUiState( val code: String = "", val step: AiturkPairStep = AiturkPairStep.IDLE, @param:StringRes val errorRes: Int? = null, + /** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */ + val errorCode: String = "", /** Address of a deep-link pairing waiting for the user's confirmation. */ val confirmAddress: String? = null, val lanPermissionNeeded: Boolean = false, val connectionSuccess: Boolean = false, + /** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */ + val securityCode: String? = null, ) { val busy: Boolean get() = step != AiturkPairStep.IDLE } @@ -53,11 +57,11 @@ class AiturkPairViewModel( private val _uiState = MutableStateFlow(AiturkPairUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var pending: Request? = null + private var pending: AiturkPairingPayload? = null private var confirmPayload: AiturkPairingPayload? = null fun onCodeChange(raw: String) { - _uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null) } + _uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") } } fun submitCode(lanPermissionGranted: Boolean) { @@ -87,11 +91,12 @@ class AiturkPairViewModel( when (val result = AiturkPairing.parse(raw)) { is AiturkPayloadResult.Ok -> { confirmPayload = result.payload + // A relay-only link (the default) has no LAN address to show. val address = result.payload.offer.endpoints - .first() - .hostPort - _uiState.update { it.copy(confirmAddress = address, errorRes = null) } + .firstOrNull() + ?.hostPort ?: RELAY_DISPLAY_NAME + _uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") } } is AiturkPayloadResult.Invalid -> { @@ -115,13 +120,12 @@ class AiturkPairViewModel( fun onLanPermissionResult(granted: Boolean) { _uiState.update { it.copy(lanPermissionNeeded = false) } - val request = pending + val payload = pending pending = null - if (!granted) { - showError(R.string.aiturk_pair_error_lan_permission) - } else if (request != null) { - run(request) - } + // A denied permission no longer stops the pairing: the tunnel needs no + // local-network access. Only the LAN candidates are skipped, and if + // nothing else works the result says so (`lan:permission`). + if (payload != null) run(payload, lanAllowed = granted) } /** The ViewModel outlives the screen; reset what must not replay on re-entry. */ @@ -131,9 +135,11 @@ class AiturkPairViewModel( _uiState.update { it.copy( errorRes = null, + errorCode = "", confirmAddress = null, lanPermissionNeeded = false, connectionSuccess = false, + securityCode = null, ) } } @@ -143,26 +149,69 @@ class AiturkPairViewModel( lanPermissionGranted: Boolean, ) { if (_uiState.value.busy) return - if (!lanPermissionGranted) { - pending = request - _uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null) } - return + when (request) { + is Request.Payload -> { + connect(request.payload, lanPermissionGranted) + } + + is Request.Code -> { + _uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") } + viewModelScope.launch { + val resolved = + try { + client.resolve(request.code) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception") + } + _uiState.update { it.copy(step = AiturkPairStep.IDLE) } + when (resolved) { + is AiturkPairResult.Success -> { + connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted) + } + + is AiturkPairResult.Failure -> { + showFailure(resolved) + } + } + } + } } - run(request) } - private fun run(request: Request) { + /** + * The local-network permission is asked for only when a LAN candidate may + * actually be tried. A pairing that carries only a relay id (the default) + * goes through the tunnel and never asks. + */ + private fun connect( + payload: AiturkPairingPayload, + lanPermissionGranted: Boolean, + ) { if (_uiState.value.busy) return - val firstStep = if (request is Request.Code) AiturkPairStep.RESOLVING else AiturkPairStep.CONNECTING - _uiState.update { it.copy(step = firstStep, errorRes = null) } + if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) { + pending = payload + _uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") } + return + } + run(payload, lanAllowed = lanPermissionGranted) + } + + private fun run( + payload: AiturkPairingPayload, + lanAllowed: Boolean, + ) { + if (_uiState.value.busy) return + _uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") } viewModelScope.launch { val outcome = try { - pair(request) + client.pair(payload.offer, payload.code, deviceName, lanAllowed) } catch (e: CancellationException) { throw e } catch (_: Exception) { - AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE) + AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception") } when (outcome) { is AiturkPairResult.Success -> { @@ -172,46 +221,43 @@ class AiturkPairViewModel( step = AiturkPairStep.IDLE, code = "", connectionSuccess = saved, + securityCode = if (saved) securityCode(outcome.value.fingerprint) else null, errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed, + errorCode = if (saved) "" else "app:save_failed", ) } } is AiturkPairResult.Failure -> { - _uiState.update { it.copy(step = AiturkPairStep.IDLE, errorRes = pairErrorRes(outcome.error)) } + showFailure(outcome) } } } } - private suspend fun pair(request: Request): AiturkPairResult { - val offer: AiturkPairingOffer - val code: String - when (request) { - is Request.Payload -> { - offer = request.payload.offer - code = request.payload.code - } - - is Request.Code -> { - code = request.code - when (val resolved = client.resolve(code)) { - is AiturkPairResult.Success -> offer = resolved.value - is AiturkPairResult.Failure -> return resolved - } - _uiState.update { it.copy(step = AiturkPairStep.CONNECTING) } - } + private fun showFailure(failure: AiturkPairResult.Failure) { + _uiState.update { + it.copy( + step = AiturkPairStep.IDLE, + errorRes = pairErrorRes(failure.error), + errorCode = AiturkDiag.sanitize(failure.code), + ) } - return client.pair(offer, code, deviceName) } private fun showError( @StringRes res: Int, ) { - _uiState.update { it.copy(errorRes = res) } + _uiState.update { it.copy(errorRes = res, errorCode = "") } } companion object { + const val SECURITY_CODE_LENGTH = 8 + private const val RELAY_DISPLAY_NAME = "turkservis.online" + + /** What both screens show so the user can compare them. */ + fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH) + @StringRes fun payloadErrorRes(error: AiturkPayloadError): Int = when (error) { @@ -223,6 +269,7 @@ class AiturkPairViewModel( AiturkPayloadError.BAD_FINGERPRINT, AiturkPayloadError.BAD_ADDRESS, AiturkPayloadError.BAD_ADDRESS_COUNT, + AiturkPayloadError.BAD_RELAY, AiturkPayloadError.BAD_CODE, -> R.string.aiturk_pair_error_qr_invalid } @@ -238,6 +285,22 @@ class AiturkPairViewModel( AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable + AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline + + AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout + + AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy + + AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown + + AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin + + AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet + + AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay + + AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission + AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b1b11b2e..9d4f83b9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1866,7 +1866,7 @@ Bilgisayara bağlan (QR / kod) Bilgisayara bağlan - Bilgisayarınızda AITURK IDE → Ayarlar → Telefon bağlantısı ekranını açın. Orada görünen QR kodu okutun ya da 8 karakterli kodu yazın. Telefon ve bilgisayar aynı Wi-Fi ağında olmalı. + Bilgisayarınızda AITURK IDE → Ayarlar → Telefon bağlantısı ekranını açın. Orada görünen QR kodu okutun ya da 8 karakterli kodu yazın. İnternetin olduğu her yerden çalışır; aynı Wi-Fi gerekmez. Bilgisayar açık ve AITURK IDE çalışıyor olmalı. QR okut Kamerayı AITURK IDE\'deki QR koda tutun ya da @@ -1878,9 +1878,9 @@ Kod aranıyor… Bilgisayara bağlanılıyor… Bu bilgisayara bağlanılsın mı? - Bir bağlantı, AITURK Mobil\'in ağınızdaki %1$s adresli bilgisayara bağlanmasını istiyor. Yalnızca bu bağlantıyı AITURK IDE\'den kendiniz açtıysanız devam edin. + Bir bağlantı, AITURK Mobil\'in %1$s adresli bilgisayara bağlanmasını istiyor. Yalnızca bu bağlantıyı AITURK IDE\'den kendiniz açtıysanız devam edin. Kamera izni verilmedi. Bunun yerine 8 karakterli kodu yazabilirsiniz. - Yerel ağdaki cihazlara erişim izni verilmedi. Bilgisayarınıza bağlanmak için bu izni verin. + Yerel ağdaki cihazlara erişim izni verilmediği için bilgisayara Wi-Fi üzerinden ulaşılamadı. Telefonun ayarlarından bu izni verip tekrar deneyin. Bu bir AITURK eşleştirme QR kodu değil. Bu QR kod için AITURK Mobil\'in daha yeni bir sürümü gerekiyor. Lütfen uygulamayı güncelleyin. QR kod eksik ya da bozuk. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açıp tekrar deneyin. @@ -1894,4 +1894,16 @@ Çok fazla yanlış kod girildi, eşleştirme iptal edildi. AITURK IDE\'de Telefon bağlantısını yeniden başlatın. Bilgisayar beklenmeyen bir cevap verdi. AITURK IDE ve AITURK Mobil\'i güncelleyip tekrar deneyin. Eşleşti ama bağlantı kaydedilemedi. Lütfen tekrar deneyin. + Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı. AITURK IDE\'yi açıp Telefon bağlantısını etkinleştirin ve tekrar deneyin. + Bilgisayar cevap vermedi. AITURK IDE\'nin çalıştığını kontrol edip tekrar deneyin. + Çok fazla bağlantı, biraz sonra yeniden deneyin. + Bu eşleşme artık geçerli değil, AITURK IDE\'den yeniden eşleştirin. + Bilgisayarın kimliği doğrulanamadı, AITURK IDE\'den yeniden eşleştirin. + Bağlandı + Güvenlik kodu: %1$s\n\nAITURK IDE\'de QR kodun altında görünen güvenlik koduyla aynı olmalı. Bağlantı uçtan uca şifrelidir. + Tamam + İnternete ulaşılamadı. Telefonun Wi-Fi ya da mobil veri bağlantısını kontrol edip tekrar deneyin. + Bağlantı hatası. Biraz sonra tekrar deneyin; sürerse AITURK IDE ve AITURK Mobil\'i güncelleyin. + Tanı kodu: %1$s + Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85370f57..86f64a41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1866,7 +1866,7 @@ Connect to computer (QR / code) Connect to your computer - On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. The phone and the computer must be on the same Wi-Fi. + On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. It works from anywhere with internet; the same Wi-Fi is not required. The computer must be on with AITURK IDE running. Scan QR Point the camera at the QR code in AITURK IDE or @@ -1878,9 +1878,9 @@ Looking up the code… Connecting to your computer… Connect to this computer? - A link is asking AITURK Mobil to connect to the computer at %1$s on your network. Continue only if you opened this link yourself from AITURK IDE. + A link is asking AITURK Mobil to connect to the computer at %1$s. Continue only if you opened this link yourself from AITURK IDE. Camera permission was not granted. You can type the 8-character code instead. - Access to devices on your local network was not allowed. Allow it to connect to your computer. + Access to devices on your local network was not allowed, so the computer could not be reached over Wi-Fi. Allow the permission in the phone\'s settings and try again. This is not an AITURK pairing QR code. This QR code needs a newer AITURK Mobil. Please update the app. The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry. @@ -1894,4 +1894,16 @@ Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE. The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again. Paired, but the connection could not be saved. Please try again. + AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again. + The computer did not answer. Check that AITURK IDE is running and try again. + Too many connections. Try again in a moment. + This pairing is no longer valid. Pair again from AITURK IDE. + The computer\'s identity could not be verified. Pair again from AITURK IDE. + Connected + Security code: %1$s\n\nIt should match the security code shown under the QR code in AITURK IDE. The connection is end-to-end encrypted. + OK + The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again. + Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil. + Diagnostic code: %1$s + AITURK IDE is not open on the computer, or Phone connection is turned off diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkFakeRelay.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkFakeRelay.kt new file mode 100644 index 00000000..59c7e320 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkFakeRelay.kt @@ -0,0 +1,148 @@ +package com.m57.hermescontrol.data.aiturk + +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.ByteString +import okio.ByteString.Companion.toByteString +import java.net.InetAddress +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.thread + +/** + * A local stand-in for the turkservis.online tunnel relay: a WebSocket endpoint + * (`/t/`) that bridges binary frames to a TCP socket of [targetPort] — + * exactly what gateway + IDE relay client do together. It never looks inside + * the bytes, so the TLS handshake with the fake IDE really runs end to end. + */ +internal class FakeRelay( + private val targetPort: () -> Int, +) { + val server = MockWebServer() + val streams = AtomicInteger() + val bytesFromPhone = AtomicLong() + val paths = CopyOnWriteArrayList() + val headerNames = CopyOnWriteArrayList() + val textFrames = AtomicInteger() + + /** When set, every new stream is accepted and immediately closed with this code. */ + @Volatile + var closeWith: Int? = null + + private val sockets = CopyOnWriteArrayList() + private val webSockets = CopyOnWriteArrayList() + + /** Cuts every open stream abnormally, like a network change (Wi-Fi ⇄ mobile data). */ + fun dropStreams() { + webSockets.forEach { runCatching { it.cancel() } } + sockets.forEach { runCatching { it.close() } } + } + + val wsBase: String get() = "ws://$LOOPBACK:${server.port}/t/" + + fun connector(): AiturkTunnelConnector = WebSocketTunnelConnector(wsBase, OkHttpClient()) + + fun start(): FakeRelay { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + paths += request.path.orEmpty() + headerNames += request.headers.names().map { it.lowercase() } + return MockResponse().withWebSocketUpgrade(Bridge()) + } + } + server.start(InetAddress.getByName(LOOPBACK), 0) + return this + } + + fun stop() { + sockets.forEach { runCatching { it.close() } } + runCatching { server.shutdown() } + } + + private inner class Bridge : WebSocketListener() { + @Volatile + private var tcp: Socket? = null + + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + streams.incrementAndGet() + webSockets += webSocket + closeWith?.let { + webSocket.close(it, null) + return + } + val socket = Socket(LOOPBACK, targetPort()) + sockets += socket + tcp = socket + thread(isDaemon = true, name = "fake-relay-pump") { + val buffer = ByteArray(BUFFER) + try { + val input = socket.getInputStream() + while (true) { + val read = input.read(buffer) + if (read < 0) break + webSocket.send(buffer.toByteString(0, read)) + } + webSocket.close(NORMAL, null) + } catch (_: Exception) { + webSocket.close(NORMAL, null) + } + } + } + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ) { + bytesFromPhone.addAndGet(bytes.size.toLong()) + val socket = tcp ?: return + runCatching { + socket.getOutputStream().write(bytes.toByteArray()) + socket.getOutputStream().flush() + } + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + textFrames.incrementAndGet() + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(code, null) + runCatching { tcp?.close() } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + runCatching { tcp?.close() } + } + } + + companion object { + const val LOOPBACK = "127.0.0.1" + const val RELAY_ID = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f" + const val OTHER_RELAY_ID = "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d" + private const val BUFFER = 16 * 1024 + private const val NORMAL = 1000 + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkRelayPairingTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkRelayPairingTest.kt new file mode 100644 index 00000000..71c9d800 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkRelayPairingTest.kt @@ -0,0 +1,393 @@ +package com.m57.hermescontrol.data.aiturk + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.io.InterruptedIOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.ServerSocket +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.security.cert.CertificateException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +/** Contract v3 pairing order: tunnel first, LAN second, exactly one `claim`. */ +class AiturkRelayPairingTest { + private lateinit var gateway: MockWebServer + private lateinit var relayIde: FakeIde + private lateinit var relay: FakeRelay + private val others = mutableListOf() + + @BeforeEach + fun setUp() { + gateway = MockWebServer() + gateway.start() + relayIde = FakeIde().start() + relay = FakeRelay { relayIde.server.port }.start() + } + + @AfterEach + fun tearDown() { + relay.stop() + runCatching { gateway.shutdown() } + (others + relayIde).forEach { runCatching { it.stop() } } + } + + private fun client( + helloTimeoutMs: Long = 2_500L, + relayHelloTimeoutMs: Long = 5_000L, + ) = AiturkPairingClient( + gatewayBaseUrl = gateway.url("/"), + helloTimeoutMs = helloTimeoutMs, + relayHelloTimeoutMs = relayHelloTimeoutMs, + tunnel = { relay.connector() }, + ) + + private fun closedPort(): Int = ServerSocket(0).use { it.localPort } + + private fun AiturkPairResult.failure(): AiturkPairResult.Failure = this as AiturkPairResult.Failure + + // ── Order ──────────────────────────────────────────────────────────── + + @Test + fun relayIsTriedFirstAndLanCandidatesAreNeverContacted() = + runBlocking { + // Same certificate on the LAN listener, as with the real IDE. + val lan = FakeIde(relayIde.certificate).start().also { others += it } + val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID) + + val device = (client().pair(offer, "ABCDEFGH", "Galaxy S24") as AiturkPairResult.Success).value + + assertEquals("https://${FakeRelay.RELAY_ID}.relay.aiturk.invalid/", device.baseUrl) + assertEquals("${FakeRelay.RELAY_ID}.relay.aiturk.invalid:443", device.hostPort) + assertNull(device.endpoint) + assertTrue(device.viaRelay) + assertEquals(relayIde.fingerprint, device.fingerprint) + assertEquals(FakeIde.TOKEN, device.token) + assertEquals(listOf("/aiturk/pair/hello", "/aiturk/pair/claim"), relayIde.paths.toList()) + assertEquals("""{"code":"ABCDEFGH","device_name":"Galaxy S24"}""", relayIde.claimBodies.single()) + assertTrue(lan.paths.isEmpty(), "LAN must not be contacted when the relay answered: ${lan.paths}") + } + + @Test + fun relayOnlyOfferPairsWithoutAnyLanAddress() = + runBlocking { + val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID) + + // lanAllowed = false: the local-network permission is irrelevant for the tunnel. + val result = client().pair(offer, "ABCDEFGH", "x", lanAllowed = false) + + assertTrue(result is AiturkPairResult.Success) + assertEquals(1, relayIde.claimCount) + } + + @Test + fun hostOfflineOnTheRelayFallsBackToTheLan() = + runBlocking { + relay.closeWith = 4503 + val lan = FakeIde().start().also { others += it } + val offer = AiturkPairingOffer(listOf(lan.endpoint), lan.fingerprint, FakeRelay.RELAY_ID) + + val device = (client().pair(offer, "ABCDEFGH", "x") as AiturkPairResult.Success).value + + assertEquals(lan.endpoint, device.endpoint) + assertEquals(lan.endpoint.baseUrl, device.baseUrl) + assertEquals(lan.endpoint.hostPort, device.hostPort) + assertFalse(device.viaRelay) + assertEquals(1, lan.claimCount) + assertEquals(0, relayIde.claimCount) + assertEquals(1, relay.streams.get()) + } + + @Test + fun hostOfflineThenUnreachableLanKeepsBothCodes() = + runBlocking { + relay.closeWith = 4503 + val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort()) + val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID) + + val failure = client().pair(offer, "ABCDEFGH", "x").failure() + + assertEquals(AiturkPairError.HOST_OFFLINE, failure.error) + assertEquals("relay:host_offline,lan:refused", failure.code) + assertEquals(0, relayIde.claimCount) + } + + @Test + fun relayFailuresAreToldApart() = + runBlocking { + val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID) + val cases = + listOf( + Triple(4503, AiturkPairError.HOST_OFFLINE, "relay:host_offline,lan:no_candidates"), + Triple(4404, AiturkPairError.RELAY_UNKNOWN, "relay:unknown,lan:no_candidates"), + Triple(4429, AiturkPairError.RELAY_BUSY, "relay:busy,lan:no_candidates"), + Triple(4504, AiturkPairError.HOST_TIMEOUT, "relay:timeout,lan:no_candidates"), + ) + cases.forEach { (closeCode, error, code) -> + relay.closeWith = closeCode + val failure = client().pair(offer, "ABCDEFGH", "x").failure() + assertEquals(error, failure.error, "close $closeCode") + assertEquals(code, failure.code, "close $closeCode") + } + + relay.stop() + val offline = client().pair(offer, "ABCDEFGH", "x").failure() + assertEquals(AiturkPairError.NO_INTERNET, offline.error) + assertEquals("relay:network,lan:no_candidates", offline.code) + assertEquals(0, relayIde.claimCount) + } + + @Test + fun wrongCertificateInsideTheTunnelIsAPinFailure() = + runBlocking { + val offer = AiturkPairingOffer(emptyList(), FakeIde.fingerprintOf(FakeIde.selfSigned()), FakeRelay.RELAY_ID) + + val failure = client().pair(offer, "ABCDEFGH", "x").failure() + + assertEquals(AiturkPairError.TLS_PIN, failure.error) + assertEquals("tls:pin,lan:no_candidates", failure.code) + assertTrue(relayIde.paths.isEmpty(), "nothing may be sent to an unverified computer: ${relayIde.paths}") + } + + @Test + fun silentComputerBehindAnOpenTunnelTimesOut() = + runBlocking { + relayIde.hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS) + val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID) + + val failure = client(relayHelloTimeoutMs = 600L).pair(offer, "ABCDEFGH", "x").failure() + + assertEquals(AiturkPairError.HOST_TIMEOUT, failure.error) + assertEquals("relay:timeout,lan:no_candidates", failure.code) + } + + // ── One claim ──────────────────────────────────────────────────────── + + @Test + fun claimAnsweredOverTheRelayIsNeverRepeatedOnTheLan() = + runBlocking { + relayIde.claim = FakeIde.json(401, """{"error":"wrong_code"}""") + val lan = FakeIde(relayIde.certificate).start().also { others += it } + val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID) + + val failure = client().pair(offer, "ABCDEFGH", "x").failure() + + assertEquals(AiturkPairError.WRONG_CODE, failure.error) + assertEquals("claim:wrong_code", failure.code) + assertEquals(1, relayIde.claimCount) + assertTrue(lan.paths.isEmpty(), "a failed claim must not be retried on the LAN: ${lan.paths}") + } + + @Test + fun claimCutMidFlightIsNotSentAgain() = + runBlocking { + // The computer receives the claim; the tunnel drops before its answer returns. + relayIde.claim = FakeIde.json(200, FakeIde.claimBody()).setHeadersDelay(5, TimeUnit.SECONDS) + val lan = FakeIde(relayIde.certificate).start().also { others += it } + val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID) + Thread { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (relayIde.claimCount == 0 && System.nanoTime() < deadline) Thread.sleep(10) + relay.dropStreams() + }.apply { isDaemon = true }.start() + + val result = client().pair(offer, "ABCDEFGH", "x") + + assertTrue(result is AiturkPairResult.Failure) + assertTrue(result.failure().code.startsWith("claim:relay_"), result.failure().code) + assertEquals(1, relayIde.claimCount, "the single-use code must be sent exactly once") + assertEquals(0, lan.claimCount) + } + + @Test + fun pairingClosedOverTheRelaySpendsNoClaim() = + runBlocking { + relayIde.hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1","pairing":false}""") + val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID) + + val failure = client().pair(offer, "ABCDEFGH", "x").failure() + + assertEquals(AiturkPairError.PAIRING_CLOSED, failure.error) + assertEquals("relay:pairing_closed", failure.code) + assertEquals(0, relayIde.claimCount) + } + + // ── LAN codes ──────────────────────────────────────────────────────── + + @Test + fun lanFailuresCarryTheirOwnCodes() = + runBlocking { + val refused = AiturkPairingOffer(listOf(AiturkEndpoint(FakeIde.LOOPBACK, closedPort())), relayIde.fingerprint) + val refusedFailure = client().pair(refused, "ABCDEFGH", "x").failure() + assertEquals(AiturkPairError.PC_UNREACHABLE, refusedFailure.error) + assertEquals("lan:refused", refusedFailure.code) + + val impostor = FakeIde().start().also { others += it } + val tls = AiturkPairingOffer(listOf(impostor.endpoint), relayIde.fingerprint) + val tlsFailure = client().pair(tls, "ABCDEFGH", "x").failure() + assertEquals(AiturkPairError.PC_UNREACHABLE, tlsFailure.error) + assertEquals("lan:tls", tlsFailure.code) + + val silent = + FakeIde().apply { hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS) }.start().also { others += it } + val timeout = AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint) + val timeoutFailure = client(helloTimeoutMs = 400L).pair(timeout, "ABCDEFGH", "x").failure() + assertEquals("lan:timeout", timeoutFailure.code) + + val denied = client().pair(refused, "ABCDEFGH", "x", lanAllowed = false).failure() + assertEquals(AiturkPairError.LAN_PERMISSION, denied.error) + assertEquals("lan:permission", denied.code) + + assertEquals(0, relay.streams.get(), "an offer without a relay id must never open a tunnel") + } + + @Test + fun everyExceptionTypeHasItsOwnKind() { + assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(SocketTimeoutException("x"))) + assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(InterruptedIOException("timeout"))) + assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(ConnectException("connect failed: ETIMEDOUT (Connection timed out)"))) + assertEquals(AiturkDiag.REFUSED, AiturkDiag.networkKind(ConnectException("connect failed: ECONNREFUSED (Connection refused)"))) + assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(NoRouteToHostException("x"))) + assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: EHOSTUNREACH (No route to host)"))) + assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: ENETUNREACH (Network is unreachable)"))) + assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(ConnectException("connect failed: EPERM (Operation not permitted)"))) + assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(java.net.SocketException("socket failed: EACCES (Permission denied)"))) + assertEquals(AiturkDiag.DNS, AiturkDiag.networkKind(UnknownHostException("host"))) + assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLHandshakeException("x"))) + assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLException("x"))) + assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(IOException("wrapped", CertificateException("pin")))) + assertEquals(AiturkDiag.IO, AiturkDiag.networkKind(IOException("unexpected end of stream"))) + + assertEquals(AiturkPairError.LAN_PERMISSION, AiturkPairingClient.lanError(AiturkDiag.PERMISSION)) + listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED, AiturkDiag.UNREACHABLE, AiturkDiag.TLS, AiturkDiag.IO).forEach { + assertEquals(AiturkPairError.PC_UNREACHABLE, AiturkPairingClient.lanError(it), it) + } + assertEquals(AiturkDiag.TLS, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.TLS, AiturkDiag.REFUSED))) + assertEquals(AiturkDiag.REFUSED, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED))) + } + + @Test + fun tunnelFailuresHaveTheirOwnKindsAndTexts() { + assertEquals(AiturkTunnelException.HOST_OFFLINE, AiturkDiag.tunnelKind(SSLException("x", AiturkTunnelException("host_offline", 4503)))) + assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLHandshakeException("x").apply { initCause(CertificateException("pin")) })) + assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLPeerUnverifiedException("x"))) + assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.tunnelKind(SocketTimeoutException("x"))) + assertEquals(AiturkDiag.TLS, AiturkDiag.tunnelKind(SSLException("closed"))) + assertEquals(AiturkDiag.NETWORK, AiturkDiag.tunnelKind(IOException("x"))) + + assertEquals("tls:pin", AiturkDiag.relayCode(AiturkDiag.PIN)) + assertEquals("relay:host_offline", AiturkDiag.relayCode(AiturkTunnelException.HOST_OFFLINE)) + + assertEquals(AiturkPairError.HOST_OFFLINE, AiturkPairingClient.relayError("host_offline")) + assertEquals(AiturkPairError.RELAY_UNKNOWN, AiturkPairingClient.relayError("unknown")) + assertEquals(AiturkPairError.NO_INTERNET, AiturkPairingClient.relayError("network")) + assertEquals(AiturkPairError.RELAY_BUSY, AiturkPairingClient.relayError("busy")) + assertEquals(AiturkPairError.HOST_TIMEOUT, AiturkPairingClient.relayError("timeout")) + assertEquals(AiturkPairError.TLS_PIN, AiturkPairingClient.relayError("pin")) + assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("overflow")) + assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("http_500")) + } + + @Test + fun diagnosticCodeNeverCarriesATokenACodeOrAnAddress() = + runBlocking { + relay.closeWith = 4503 + val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort()) + val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID) + val code = client().pair(offer, "ABCDEFGH", "x").failure().code + + listOf("ABCDEFGH", FakeRelay.RELAY_ID, FakeIde.LOOPBACK, dead.port.toString(), relayIde.fingerprint, "amd_", "https", ".invalid").forEach { + assertFalse(it in code, "'$it' leaked into '$code'") + } + assertEquals(code, AiturkDiag.sanitize(code)) + + // The exception message (which on Android names the address) is never copied. + val noisy = ConnectException("failed to connect to /192.168.1.20 (port 47321): connect failed: ECONNREFUSED") + assertEquals("refused", AiturkDiag.networkKind(noisy)) + + // The UI drops anything that is not built from the fixed vocabulary. + listOf( + "relay:" + FakeIde.TOKEN, + "lan:192.168.1.20:47321", + "claim:ABCDEFGH", + "relay:https://turkservis.online/x", + "relay:host_offline lan:timeout", + "x".repeat(200) + ":y", + ).forEach { assertEquals("", AiturkDiag.sanitize(it), it) } + assertEquals("relay:host_offline,lan:timeout", AiturkDiag.sanitize("relay:host_offline,lan:timeout")) + assertEquals("tls:pin", AiturkDiag.sanitize("tls:pin")) + assertEquals("resolve:http_500", AiturkDiag.sanitize("resolve:http_500")) + } + + // ── resolve ────────────────────────────────────────────────────────── + + @Test + fun resolveAcceptsARelayIdWithOrWithoutLanAddresses() = + runBlocking { + val fp = "A".repeat(43) + gateway.enqueue(FakeIde.json(200, """{"urls":[],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}","expiresIn":280}""")) + gateway.enqueue(FakeIde.json(200, """{"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}""")) + gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}""")) + gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":null}""")) + + val relayOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value + assertEquals(AiturkPairingOffer(emptyList(), fp, FakeRelay.RELAY_ID), relayOnly) + assertEquals(relayOnly, (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value) + val both = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value + assertEquals(AiturkPairingOffer(listOf(AiturkEndpoint("192.168.1.20", 47321)), fp, FakeRelay.RELAY_ID), both) + val lanOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value + assertNull(lanOnly.relay) + } + + @Test + fun resolveRejectsAnythingThatIsNotARelayId() = + runBlocking { + val fp = "A".repeat(43) + val id = FakeRelay.RELAY_ID + listOf( + """{"urls":[],"fp":"$fp","relay":"https://turkservis.online/v1/aiturk/mobile-relay/r/$id"}""", + """{"urls":[],"fp":"$fp","relay":"${id.uppercase()}"}""", + """{"urls":[],"fp":"$fp","relay":"$id.evil.example"}""", + """{"urls":[],"fp":"$fp","relay":"$id/"}""", + """{"urls":[],"fp":"$fp","relay":""}""", + """{"urls":[],"fp":"$fp","relay":42}""", + """{"urls":[],"fp":"$fp","relay":["$id"]}""", + // A wrong relay is never silently dropped in favour of the LAN addresses. + """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"evil.example"}""", + """{"urls":[],"fp":"$fp"}""", + """{"urls":"https://192.168.1.20:47321","fp":"$fp","relay":"$id"}""", + ).forEach { body -> + gateway.enqueue(FakeIde.json(200, body)) + val failure = client().resolve("ABCDEFGH").failure() + assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, failure.error, body) + assertEquals("resolve:bad_response", failure.code, body) + } + } + + @Test + fun resolveFailuresHaveCodes() = + runBlocking { + gateway.enqueue(MockResponse().setResponseCode(404)) + gateway.enqueue(MockResponse().setResponseCode(429)) + gateway.enqueue(MockResponse().setResponseCode(500)) + + assertEquals("resolve:not_found", client().resolve("ABCDEFGH").failure().code) + assertEquals("resolve:rate_limited", client().resolve("ABCDEFGH").failure().code) + assertEquals("resolve:http_500", client().resolve("ABCDEFGH").failure().code) + gateway.shutdown() + assertEquals("resolve:network", client().resolve("ABCDEFGH").failure().code) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt index 20b76f35..80780352 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt @@ -25,6 +25,10 @@ internal class FakeIde( @Volatile var claim: MockResponse = json(200, claimBody()) + /** Answers for paths other than hello/claim (null → 404). */ + @Volatile + var extra: (RecordedRequest) -> MockResponse? = { null } + val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port) val fingerprint: String get() = fingerprintOf(certificate) val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" } @@ -49,7 +53,7 @@ internal class FakeIde( } else -> { - json(404, """{"error":"not_found"}""") + extra(request) ?: json(404, """{"error":"not_found"}""") } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt index c593f1d5..5a579fbb 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt @@ -68,6 +68,33 @@ class AiturkTokenRefreshSkipTest { verify(exactly = 0) { AuthManager.setToken(any()) } } + @Test + fun tunnelPairedConnectionNeverAsksTheDashboardForAToken() = + runBlocking { + // Contract v3: a relay pairing is the virtual host, pinned on port 443. + val relayId = FakeRelay.RELAY_ID + AiturkLanTrust.pin(AiturkRelay.hostPort(relayId), "A".repeat(43)) + every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(relayId) + every { OkHttpProvider.probe } returns countingClient() + + assertNull(DashboardSessionTokenRefresher.refreshAsync()) + assertNull(DashboardSessionTokenRefresher.refresh()) + + assertEquals(0, calls.get()) + verify(exactly = 0) { AuthManager.setToken(any()) } + } + + @Test + fun unpairedVirtualHostIsNotTreatedAsPaired() = + runBlocking { + every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(FakeRelay.OTHER_RELAY_ID) + every { OkHttpProvider.probe } returns countingClient() + + assertNull(DashboardSessionTokenRefresher.refreshAsync()) + + assertEquals(1, calls.get()) + } + @Test fun unpairedConnectionStillRefreshesAsBefore() = runBlocking { diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTunnelTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTunnelTest.kt new file mode 100644 index 00000000..f16037a5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTunnelTest.kt @@ -0,0 +1,417 @@ +package com.m57.hermescontrol.data.aiturk + +import okhttp3.Dns +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.Socket +import java.net.URI +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.random.Random + +/** + * Contract v3: REST and WebSocket traffic to the virtual relay host really + * travels through the tunnel, with TLS terminated by the (fake) IDE and pinned + * to its certificate — and nothing changes for any other host. + */ +class AiturkTunnelTest { + private lateinit var ide: FakeIde + private lateinit var relay: FakeRelay + private val virtualBase = "https://${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}" + + @BeforeEach + fun setUp() { + AiturkLanTrust.resetForTest(MemoryPins()) + ide = FakeIde().start() + relay = FakeRelay { ide.server.port }.start() + } + + @AfterEach + fun tearDown() { + relay.stop() + runCatching { ide.stop() } + AiturkLanTrust.resetForTest(MemoryPins()) + } + + private fun pinnedClient(fingerprint: String = ide.fingerprint): OkHttpClient = + AiturkLanTrust.pinnedClient( + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(), + fingerprint, + ) { relay.connector() } + + // ── REST ───────────────────────────────────────────────────────────── + + @Test + fun restRequestReachesTheIdeThroughTheTunnelWithPinnedTls() { + pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute().use { response -> + assertEquals(200, response.code) + assertTrue(response.body.string().contains("aiturk-ide")) + assertNotNull(response.handshake, "TLS must be established inside the tunnel") + } + + // The IDE saw the request with NO path prefix; the relay saw only its own stream path. + assertEquals(listOf("/aiturk/pair/hello"), ide.paths.toList()) + assertEquals(listOf("/t/${FakeRelay.RELAY_ID}"), relay.paths.toList()) + assertEquals(0, relay.textFrames.get(), "the stream is binary only") + assertFalse("origin" in relay.headerNames, "headers seen by the relay: ${relay.headerNames}") + assertFalse("cookie" in relay.headerNames) + assertFalse("authorization" in relay.headerNames) + } + + @Test + fun largeBodiesSurviveTheTunnelInBothDirections() { + val payload = Random(7).nextBytes(1_500_000) + ide.extra = { request -> + if (request.path == "/api/echo") { + MockResponse().setResponseCode(200).setBody(okio.Buffer().write(request.body.readByteArray())) + } else { + null + } + } + + val request = + Request + .Builder() + .url("$virtualBase/api/echo") + .post(payload.toRequestBody("application/octet-stream".toMediaType())) + .build() + val echoed = pinnedClient().newCall(request).execute().use { it.body.bytes() } + + assertArrayEquals(payload, echoed) + } + + @Test + fun tokenTravelsOnlyInsideTls() { + val token = "amd_" + "s".repeat(43) + val seen = AtomicReference() + ide.extra = { request -> + seen.set(request.getHeader("Authorization")) + FakeIde.json(200, """{"ok":true}""") + } + val sniffed = LinkedBlockingQueue() + val sniffing = + AiturkTunnelConnector { relayId, timeoutMs -> + val inner = relay.connector().open(relayId, timeoutMs) + object : AiturkTunnelChannel by inner { + override val output = + object : java.io.OutputStream() { + override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1) + + override fun write( + b: ByteArray, + off: Int, + len: Int, + ) { + sniffed += b.copyOfRange(off, off + len) + inner.output.write(b, off, len) + } + } + } + } + val client = AiturkLanTrust.pinnedClient(OkHttpClient(), ide.fingerprint) { sniffing } + + client + .newCall( + Request + .Builder() + .url("$virtualBase/api/status?marker=PLAINTEXT-MARKER") + .header("Authorization", "Bearer $token") + .build(), + ).execute() + .close() + + assertEquals("Bearer $token", seen.get()) + val onTheWire = String(sniffed.flatMap { it.asIterable() }.toByteArray(), Charsets.ISO_8859_1) + assertTrue(onTheWire.isNotEmpty()) + assertFalse(token in onTheWire, "the device token must never be readable by the relay") + assertFalse("PLAINTEXT-MARKER" in onTheWire, "paths and queries must never be readable by the relay") + assertFalse("/api/status" in onTheWire) + } + + // ── WebSocket ──────────────────────────────────────────────────────── + + @Test + fun webSocketWorksThroughTheTunnel() { + ide.extra = { request -> + if (request.path?.startsWith("/api/ws") == true) { + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + webSocket.send("echo:$text") + } + }, + ) + } else { + null + } + } + val opened = CountDownLatch(1) + val messages = LinkedBlockingQueue() + val failure = AtomicReference() + + val socket = + pinnedClient().newWebSocket( + Request.Builder().url("$virtualBase/api/ws?token=t").build(), + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + messages += text + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + failure.set(t) + opened.countDown() + } + }, + ) + try { + assertTrue(opened.await(10, TimeUnit.SECONDS), "WebSocket did not open") + assertNull(failure.get(), "WebSocket failed: ${failure.get()}") + socket.send("hello") + assertEquals("echo:hello", messages.poll(10, TimeUnit.SECONDS)) + // The app's WebSocket URL is seen by the IDE as written (no prefix). + assertTrue(ide.paths.any { it == "/api/ws?token=t" }, "paths: ${ide.paths}") + } finally { + socket.cancel() + } + } + + // ── Trust ──────────────────────────────────────────────────────────── + + @Test + fun certificateThatDoesNotMatchThePairingFingerprintIsRejected() { + val other = FakeIde.fingerprintOf(FakeIde.selfSigned()) + + val error = + assertThrows(IOException::class.java) { + pinnedClient(other).newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute() + } + + assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(error)) + assertTrue(ide.paths.isEmpty(), "no request may be sent before the pin matched: ${ide.paths}") + } + + @Test + fun sharedClientHookUsesTheStoredPinForTheVirtualHost() { + // What OkHttpProvider.base does: AiturkLanTrust.applyTo(builder). + val previous = AiturkTunnel.connector + AiturkTunnel.connector = relay.connector() + try { + val client = AiturkLanTrust.applyTo(OkHttpClient.Builder()).build() + val request = Request.Builder().url("$virtualBase/aiturk/pair/hello").build() + + // Not paired yet: platform trust rejects the IDE's self-signed certificate. + assertThrows(IOException::class.java) { client.newCall(request).execute() } + assertTrue(ide.paths.isEmpty()) + + AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), ide.fingerprint) + assertTrue(AiturkLanTrust.isPairedUrl("$virtualBase/")) + client.newCall(request).execute().use { assertEquals(200, it.code) } + + // A pin for one relay id says nothing about another. + val otherHost = AiturkRelay.virtualHost(FakeRelay.OTHER_RELAY_ID) + assertFalse(AiturkLanTrust.isPairedUrl("https://$otherHost/")) + assertThrows(IOException::class.java) { + client.newCall(Request.Builder().url("https://$otherHost/aiturk/pair/hello").build()).execute() + } + } finally { + AiturkTunnel.connector = previous + } + } + + @Test + fun onlyAnIdeAddressCanBePinned() { + val fp = "A".repeat(43) + assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("turkservis.online:443", fp) } + assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("evil.relay.aiturk.invalid:443", fp) } + assertThrows(IllegalArgumentException::class.java) { + AiturkLanTrust.pin("${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}:8443", fp) + } + AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), fp) + AiturkLanTrust.pin("192.168.1.20:47321", fp) + } + + // ── Everything else is untouched ───────────────────────────────────── + + @Test + fun otherHostsKeepDefaultDnsSocketsAndProxy() { + val opens = AtomicInteger() + val counting = + AiturkTunnelConnector { relayId, timeoutMs -> + opens.incrementAndGet() + relay.connector().open(relayId, timeoutMs) + } + val plain = MockWebServer() + plain.enqueue(MockResponse().setBody("plain")) + plain.start() + try { + val client = AiturkTunnel.applyTo(OkHttpClient.Builder()) { counting }.build() + + val body = client.newCall(Request.Builder().url(plain.url("/x")).build()).execute().use { it.body.string() } + + assertEquals("plain", body) + assertEquals(0, opens.get(), "a normal host must never open a tunnel") + assertEquals(0, relay.streams.get()) + + // DNS: real names go to the system resolver, unchanged. + val recorded = mutableListOf() + val delegate = + Dns { host -> + recorded += host + Dns.SYSTEM.lookup(host) + } + assertEquals(Dns.SYSTEM.lookup("localhost"), AiturkTunnelDns(delegate).lookup("localhost")) + assertEquals(listOf("localhost"), recorded) + + // Socket: an ordinary TCP connection made by java.net.Socket itself. + val socket = AiturkTunnelSocketFactory { counting }.createSocket() + assertTrue(socket is Socket) + socket.use { + it.connect(InetSocketAddress(plain.hostName, plain.port), 2_000) + assertTrue(it.isConnected) + assertNull((it as AiturkTunnelSocket).channel) + assertEquals(plain.port, it.port) + } + assertEquals(0, opens.get()) + } finally { + plain.shutdown() + } + } + + @Test + fun virtualHostResolvesToAPlaceholderWithoutTouchingRealDns() { + val host = AiturkRelay.virtualHost(FakeRelay.RELAY_ID) + val delegate = Dns { throw AssertionError("system DNS must not be asked about $it") } + + val address = AiturkTunnelDns(delegate).lookup(host).single() + + assertEquals(host, address.hostName) + assertEquals("192.0.2.1", address.hostAddress) + assertEquals(listOf(Proxy.NO_PROXY), AiturkTunnelProxySelector().select(URI("https://$host/"))) + // Look-alikes are ordinary names. + listOf("relay.aiturk.invalid", "x.relay.aiturk.invalid", "${FakeRelay.RELAY_ID}.relay.aiturk.invalid.evil.example").forEach { + assertFalse(AiturkRelay.isVirtualHost(it), it) + } + } + + // ── Close codes ────────────────────────────────────────────────────── + + @Test + fun relayCloseCodesBecomeNamedFailures() { + val expected = + mapOf( + 4503 to AiturkTunnelException.HOST_OFFLINE, + 4404 to AiturkTunnelException.UNKNOWN, + 4429 to AiturkTunnelException.BUSY, + 4504 to AiturkTunnelException.TIMEOUT, + 4408 to AiturkTunnelException.OVERFLOW, + ) + expected.forEach { (code, kind) -> + relay.closeWith = code + + val error = + assertThrows(IOException::class.java) { + pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute() + } + + assertEquals(kind, AiturkDiag.tunnelKind(error), "close code $code") + assertEquals(code, AiturkTunnelException.find(error)?.closeCode) + } + assertTrue(ide.paths.isEmpty()) + assertNull(AiturkTunnelException.kindForCloseCode(1000)) + } + + @Test + fun unreachableRelayIsANetworkFailure() { + relay.stop() + + val error = + assertThrows(IOException::class.java) { + pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute() + } + + assertEquals(AiturkTunnelException.NETWORK, AiturkDiag.tunnelKind(error)) + } + + @Test + fun hostOfflineIsRememberedForTheChatBanner() { + AiturkRelayStatus.onSocketEnded(IOException("x", AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))) + assertTrue(AiturkRelayStatus.hostOffline.value) + AiturkRelayStatus.onSocketEnded(IOException("timeout")) + assertFalse(AiturkRelayStatus.hostOffline.value) + AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503)) + AiturkRelayStatus.onSocketOpened() + assertFalse(AiturkRelayStatus.hostOffline.value) + } + + // ── No replay ──────────────────────────────────────────────────────── + + @Test + fun postCutMidFlightIsNotSentAgain() { + val posts = AtomicInteger() + val arrived = CountDownLatch(1) + ide.extra = { request -> + if (request.method == "POST") posts.incrementAndGet() + arrived.countDown() + // The computer got the request; its answer never makes it back. + FakeIde.json(200, """{"ok":true}""").setHeadersDelay(5, TimeUnit.SECONDS) + } + Thread { + if (arrived.await(10, TimeUnit.SECONDS)) relay.dropStreams() + }.apply { isDaemon = true }.start() + // Same setting the pairing `claim` uses. + val client = pinnedClient().newBuilder().retryOnConnectionFailure(false).build() + val request = + Request + .Builder() + .url("$virtualBase/api/once") + .post("""{"n":1}""".toRequestBody("application/json".toMediaType())) + .build() + + assertThrows(IOException::class.java) { client.newCall(request).execute().use { it.body.string() } } + + assertEquals(1, posts.get(), "a POST that may have reached the computer must not be replayed") + assertEquals(1, relay.streams.get(), "no second tunnel stream may be opened for a retry") + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModelTest.kt new file mode 100644 index 00000000..d48d523d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairViewModelTest.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.aiturkpair + +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.aiturk.AiturkPairError +import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice +import com.m57.hermescontrol.data.aiturk.AiturkPairingClient +import com.m57.hermescontrol.data.aiturk.AiturkPayloadError +import com.m57.hermescontrol.data.aiturk.AiturkTunnelConnector +import com.m57.hermescontrol.data.aiturk.AiturkTunnelException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger + +/** + * Contract v3: the local-network permission is asked for only when a LAN + * address may be tried. The coroutine dispatcher is never advanced, so no + * network call runs: these tests look at the decision made synchronously. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiturkPairViewModelTest { + private val fp = "A".repeat(43) + private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f" + private val tunnelOpens = AtomicInteger() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(): AiturkPairViewModel { + val tunnel = + AiturkTunnelConnector { _, _ -> + tunnelOpens.incrementAndGet() + throw AiturkTunnelException(AiturkTunnelException.NETWORK) + } + return AiturkPairViewModel( + client = AiturkPairingClient(gatewayBaseUrl = "http://127.0.0.1:9/".toHttpUrl(), tunnel = { tunnel }), + register = { _: AiturkPairedDevice -> }, + deviceName = "Test phone", + ) + } + + @Test + fun relayOnlyQrNeverAsksForTheLocalNetworkPermission() { + val vm = viewModel() + + vm.onQrScanned("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false) + + val state = vm.uiState.value + assertFalse(state.lanPermissionNeeded, "a relay-only pairing must not ask for ACCESS_LOCAL_NETWORK") + assertEquals(AiturkPairStep.CONNECTING, state.step) + assertNull(state.errorRes) + } + + @Test + fun relayOnlyDeepLinkNeverAsksEither() { + val vm = viewModel() + + vm.onDeepLink("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId") + assertEquals("turkservis.online", vm.uiState.value.confirmAddress) + vm.confirmDeepLink(lanPermissionGranted = false) + + assertFalse(vm.uiState.value.lanPermissionNeeded) + assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step) + } + + @Test + fun qrWithALanAddressStillAsksBeforeConnecting() { + val vm = viewModel() + + vm.onQrScanned("aiturk://pair?v=1&u=192.168.1.20:47321&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false) + + assertTrue(vm.uiState.value.lanPermissionNeeded) + assertEquals(AiturkPairStep.IDLE, vm.uiState.value.step) + + // Denying it no longer ends the pairing: the tunnel is still tried. + vm.onLanPermissionResult(granted = false) + assertFalse(vm.uiState.value.lanPermissionNeeded) + assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step) + assertNull(vm.uiState.value.errorRes) + } + + @Test + fun typedCodeResolvesFirstWithoutAskingForThePermission() { + val vm = viewModel() + vm.onCodeChange("ABCDEFGH") + + vm.submitCode(lanPermissionGranted = false) + + // Whether a LAN address exists is only known after `resolve`. + assertFalse(vm.uiState.value.lanPermissionNeeded) + assertEquals(AiturkPairStep.RESOLVING, vm.uiState.value.step) + } + + @Test + fun everyFailureHasItsOwnText() { + val texts = AiturkPairError.entries.associateWith(AiturkPairViewModel::pairErrorRes) + + assertEquals(R.string.aiturk_pair_error_host_offline, texts[AiturkPairError.HOST_OFFLINE]) + assertEquals(R.string.aiturk_pair_error_relay_unknown, texts[AiturkPairError.RELAY_UNKNOWN]) + assertEquals(R.string.aiturk_pair_error_no_internet, texts[AiturkPairError.NO_INTERNET]) + assertEquals(R.string.aiturk_pair_error_relay_busy, texts[AiturkPairError.RELAY_BUSY]) + assertEquals(R.string.aiturk_pair_error_host_timeout, texts[AiturkPairError.HOST_TIMEOUT]) + assertEquals(R.string.aiturk_pair_error_tls_pin, texts[AiturkPairError.TLS_PIN]) + assertEquals(R.string.aiturk_pair_error_pc_unreachable, texts[AiturkPairError.PC_UNREACHABLE]) + assertEquals(R.string.aiturk_pair_error_lan_permission, texts[AiturkPairError.LAN_PERMISSION]) + assertEquals(R.string.aiturk_pair_error_wrong_code, texts[AiturkPairError.WRONG_CODE]) + assertEquals(R.string.aiturk_pair_error_pairing_closed, texts[AiturkPairError.PAIRING_CLOSED]) + assertEquals(R.string.aiturk_pair_error_pairing_locked, texts[AiturkPairError.PAIRING_LOCKED]) + // The named failures never collapse into one message. + val distinct = + listOf( + AiturkPairError.HOST_OFFLINE, + AiturkPairError.RELAY_UNKNOWN, + AiturkPairError.NO_INTERNET, + AiturkPairError.RELAY_BUSY, + AiturkPairError.HOST_TIMEOUT, + AiturkPairError.TLS_PIN, + AiturkPairError.PC_UNREACHABLE, + AiturkPairError.LAN_PERMISSION, + AiturkPairError.WRONG_CODE, + AiturkPairError.PAIRING_CLOSED, + AiturkPairError.PAIRING_LOCKED, + AiturkPairError.CODE_NOT_FOUND, + AiturkPairError.RATE_LIMITED, + ).map { texts.getValue(it) } + assertEquals(distinct.size, distinct.toSet().size) + assertEquals(R.string.aiturk_pair_error_qr_invalid, AiturkPairViewModel.payloadErrorRes(AiturkPayloadError.BAD_RELAY)) + } + + @Test + fun securityCodeIsTheFirstEightCharactersOfTheFingerprint() { + assertEquals("AbCdEfGh", AiturkPairViewModel.securityCode("AbCdEfGh" + "x".repeat(35))) + assertEquals(0, tunnelOpens.get()) + } +}