Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d828b90a | ||
|
|
cbe716300c | ||
|
|
9afac6f508 |
No files matched your search
@@ -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://<relayId>.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() }
|
||||
|
||||
|
||||
@@ -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<String, String>()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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://<ip:port>/` ([endpoint] set) or the virtual tunnel host
|
||||
* `https://<relayId>.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<out T> {
|
||||
data class Success<T>(
|
||||
val value: T,
|
||||
) : AiturkPairResult<T>
|
||||
|
||||
/**
|
||||
* [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<Nothing>
|
||||
}
|
||||
|
||||
/** 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:<kind>`, 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<AiturkPairingOffer> =
|
||||
@@ -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<AiturkPairedDevice> =
|
||||
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<AiturkPairedDevice> {
|
||||
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_<status>`. */
|
||||
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<AiturkEndpoint>,
|
||||
): Reachable? {
|
||||
): Hello {
|
||||
val helloClient =
|
||||
pinned
|
||||
.newBuilder()
|
||||
@@ -153,78 +383,128 @@ class AiturkPairingClient(
|
||||
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val failures = CopyOnWriteArrayList<String>()
|
||||
// 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<Hello>(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<AiturkPairedDevice> {
|
||||
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<AiturkPairedDevice> {
|
||||
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<AiturkPairingOffer> {
|
||||
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>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import java.util.Locale
|
||||
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
||||
*
|
||||
* QR / deep-link payload:
|
||||
* `aiturk://pair?v=1&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
|
||||
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
|
||||
*
|
||||
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
|
||||
* sends by default; `u` (0..4) is only present when the user allowed direct
|
||||
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
|
||||
*/
|
||||
data class AiturkEndpoint(
|
||||
val host: String,
|
||||
@@ -23,10 +27,14 @@ data class AiturkEndpoint(
|
||||
val baseUrl: String get() = "https://$host:$port/"
|
||||
}
|
||||
|
||||
/** A pairing offer: where the IDE listens and which certificate it must present. */
|
||||
/**
|
||||
* A pairing offer: where the IDE listens and which certificate it must present
|
||||
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
|
||||
*/
|
||||
data class AiturkPairingOffer(
|
||||
val endpoints: List<AiturkEndpoint>,
|
||||
val fingerprint: String,
|
||||
val relay: String? = null,
|
||||
)
|
||||
|
||||
data class AiturkPairingPayload(
|
||||
@@ -50,9 +58,12 @@ enum class AiturkPayloadError {
|
||||
/** A `u` entry is not `<private IPv4>:<port>`. */
|
||||
BAD_ADDRESS,
|
||||
|
||||
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
|
||||
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
|
||||
BAD_ADDRESS_COUNT,
|
||||
|
||||
/** `r` is present but is not a lowercase UUIDv4 relay id. */
|
||||
BAD_RELAY,
|
||||
|
||||
/** `c` is not 8 characters of the pairing alphabet. */
|
||||
BAD_CODE,
|
||||
}
|
||||
@@ -106,8 +117,18 @@ object AiturkPairing {
|
||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
|
||||
}
|
||||
|
||||
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
|
||||
// never silently dropped in favour of the LAN candidates.
|
||||
val rawRelays = params.filter { it.first == "r" }.map { it.second }
|
||||
val relay =
|
||||
when (rawRelays.size) {
|
||||
0 -> null
|
||||
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||
}
|
||||
|
||||
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
|
||||
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
|
||||
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
|
||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
|
||||
}
|
||||
val endpoints =
|
||||
@@ -121,7 +142,7 @@ object AiturkPairing {
|
||||
|
||||
return AiturkPayloadResult.Ok(
|
||||
AiturkPairingPayload(
|
||||
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
|
||||
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
|
||||
code = code,
|
||||
),
|
||||
)
|
||||
@@ -194,3 +215,42 @@ object AiturkPairing {
|
||||
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
||||
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
|
||||
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
|
||||
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
|
||||
* internet; [AiturkTunnel] turns connections to it into an end-to-end
|
||||
* encrypted stream through the relay.
|
||||
*/
|
||||
object AiturkRelay {
|
||||
const val HOST_SUFFIX = ".relay.aiturk.invalid"
|
||||
const val PORT = 443
|
||||
|
||||
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
private val relayId = Regex(UUID4)
|
||||
|
||||
/**
|
||||
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
|
||||
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
|
||||
* surrounding whitespace.
|
||||
*/
|
||||
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
|
||||
|
||||
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
|
||||
|
||||
/** Key used by the certificate pin store for a relay pairing. */
|
||||
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
|
||||
|
||||
/** The saved connection base: no path prefix, trailing slash. */
|
||||
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
|
||||
|
||||
/** The relay id behind a virtual host name, or null for any other host. */
|
||||
fun relayIdOfHost(host: String?): String? =
|
||||
host
|
||||
?.takeIf { it.endsWith(HOST_SUFFIX) }
|
||||
?.removeSuffix(HOST_SUFFIX)
|
||||
?.let(::parse)
|
||||
|
||||
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
|
||||
}
|
||||
@@ -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://<relayId>.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/<relayId>` 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:<kind>` 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<AiturkTunnelException>()
|
||||
.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<InetAddress> =
|
||||
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<Proxy> {
|
||||
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<String> = delegate.defaultCipherSuites
|
||||
|
||||
override fun getSupportedCipherSuites(): Array<String> = 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<ByteString>()
|
||||
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<Boolean> = _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
|
||||
}
|
||||
}
|
||||
@@ -730,6 +730,8 @@ internal class WsTransport(
|
||||
// Do NOT log [reason] — it may carry server-side context. The
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||
.onSocketEnded(null)
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
connectionHealth.stop()
|
||||
@@ -764,6 +766,10 @@ internal class WsTransport(
|
||||
connectionHealth.stop()
|
||||
rpcChannel.clearCapabilityRequests()
|
||||
val code = response?.code ?: 0
|
||||
// AITURK: lets the chat banner say "AITURK IDE is not open on the
|
||||
// computer" when the tunnel reports that no host is attached.
|
||||
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||
.onSocketEnded(t)
|
||||
if (code == 401 || code == 4401 || code == 4403 ||
|
||||
t.message?.contains("401") == true ||
|
||||
t.message?.contains("4401") == true ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AiturkPairUiState> = _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<AiturkPairedDevice> {
|
||||
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
|
||||
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -70,13 +72,20 @@ fun ChatConnectionBanner(
|
||||
color = LocalHermesStatusColors.current.error,
|
||||
)
|
||||
}
|
||||
// AITURK: over the relay, say why the reconnect keeps failing.
|
||||
val aiturkHostOffline by com.m57.hermescontrol.data.aiturk.AiturkRelayStatus.hostOffline
|
||||
.collectAsState()
|
||||
val hostOffline = aiturkHostOffline && connectionStatus == ConnectionStatus.RECONNECTING
|
||||
Text(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
maxLines = 1,
|
||||
maxLines = if (hostOffline) 3 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
text =
|
||||
when (connectionStatus) {
|
||||
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
|
||||
ConnectionStatus.RECONNECTING ->
|
||||
stringResource(
|
||||
if (hostOffline) R.string.aiturk_status_host_offline else R.string.chat_status_reconnecting,
|
||||
)
|
||||
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
|
||||
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
|
||||
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
|
||||
|
||||
@@ -1866,7 +1866,7 @@
|
||||
<!-- AITURK IDE pairing (QR / code) -->
|
||||
<string name="landing_action_aiturk_pair">Bilgisayara bağlan (QR / kod)</string>
|
||||
<string name="aiturk_pair_title">Bilgisayara bağlan</string>
|
||||
<string name="aiturk_pair_desc">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ı.</string>
|
||||
<string name="aiturk_pair_desc">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ı.</string>
|
||||
<string name="aiturk_pair_scan_qr">QR okut</string>
|
||||
<string name="aiturk_pair_scan_prompt">Kamerayı AITURK IDE\'deki QR koda tutun</string>
|
||||
<string name="aiturk_pair_or">ya da</string>
|
||||
@@ -1878,9 +1878,9 @@
|
||||
<string name="aiturk_pair_progress_resolve">Kod aranıyor…</string>
|
||||
<string name="aiturk_pair_progress_connect">Bilgisayara bağlanılıyor…</string>
|
||||
<string name="aiturk_pair_confirm_title">Bu bilgisayara bağlanılsın mı?</string>
|
||||
<string name="aiturk_pair_confirm_message">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.</string>
|
||||
<string name="aiturk_pair_confirm_message">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.</string>
|
||||
<string name="aiturk_pair_error_camera_denied">Kamera izni verilmedi. Bunun yerine 8 karakterli kodu yazabilirsiniz.</string>
|
||||
<string name="aiturk_pair_error_lan_permission">Yerel ağdaki cihazlara erişim izni verilmedi. Bilgisayarınıza bağlanmak için bu izni verin.</string>
|
||||
<string name="aiturk_pair_error_lan_permission">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.</string>
|
||||
<string name="aiturk_pair_error_qr_not_aiturk">Bu bir AITURK eşleştirme QR kodu değil.</string>
|
||||
<string name="aiturk_pair_error_update_app">Bu QR kod için AITURK Mobil\'in daha yeni bir sürümü gerekiyor. Lütfen uygulamayı güncelleyin.</string>
|
||||
<string name="aiturk_pair_error_qr_invalid">QR kod eksik ya da bozuk. AITURK IDE\'de Telefon bağlantısı ekranını yeniden açıp tekrar deneyin.</string>
|
||||
@@ -1894,4 +1894,16 @@
|
||||
<string name="aiturk_pair_error_pairing_locked">Çok fazla yanlış kod girildi, eşleştirme iptal edildi. AITURK IDE\'de Telefon bağlantısını yeniden başlatın.</string>
|
||||
<string name="aiturk_pair_error_unexpected">Bilgisayar beklenmeyen bir cevap verdi. AITURK IDE ve AITURK Mobil\'i güncelleyip tekrar deneyin.</string>
|
||||
<string name="aiturk_pair_error_save_failed">Eşleşti ama bağlantı kaydedilemedi. Lütfen tekrar deneyin.</string>
|
||||
<string name="aiturk_pair_error_host_offline">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.</string>
|
||||
<string name="aiturk_pair_error_host_timeout">Bilgisayar cevap vermedi. AITURK IDE\'nin çalıştığını kontrol edip tekrar deneyin.</string>
|
||||
<string name="aiturk_pair_error_relay_busy">Çok fazla bağlantı, biraz sonra yeniden deneyin.</string>
|
||||
<string name="aiturk_pair_error_relay_unknown">Bu eşleşme artık geçerli değil, AITURK IDE\'den yeniden eşleştirin.</string>
|
||||
<string name="aiturk_pair_error_tls_pin">Bilgisayarın kimliği doğrulanamadı, AITURK IDE\'den yeniden eşleştirin.</string>
|
||||
<string name="aiturk_pair_security_title">Bağlandı</string>
|
||||
<string name="aiturk_pair_security_message">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.</string>
|
||||
<string name="aiturk_pair_security_ok">Tamam</string>
|
||||
<string name="aiturk_pair_error_no_internet">İnternete ulaşılamadı. Telefonun Wi-Fi ya da mobil veri bağlantısını kontrol edip tekrar deneyin.</string>
|
||||
<string name="aiturk_pair_error_relay">Bağlantı hatası. Biraz sonra tekrar deneyin; sürerse AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
|
||||
<string name="aiturk_pair_error_code">Tanı kodu: %1$s</string>
|
||||
<string name="aiturk_status_host_offline">Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı</string>
|
||||
</resources>
|
||||
@@ -1866,7 +1866,7 @@
|
||||
<!-- AITURK IDE pairing (QR / code) -->
|
||||
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
|
||||
<string name="aiturk_pair_title">Connect to your computer</string>
|
||||
<string name="aiturk_pair_desc">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.</string>
|
||||
<string name="aiturk_pair_desc">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.</string>
|
||||
<string name="aiturk_pair_scan_qr">Scan QR</string>
|
||||
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
|
||||
<string name="aiturk_pair_or">or</string>
|
||||
@@ -1878,9 +1878,9 @@
|
||||
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
|
||||
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
|
||||
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
|
||||
<string name="aiturk_pair_confirm_message">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.</string>
|
||||
<string name="aiturk_pair_confirm_message">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.</string>
|
||||
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
|
||||
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed. Allow it to connect to your computer.</string>
|
||||
<string name="aiturk_pair_error_lan_permission">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.</string>
|
||||
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
|
||||
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
|
||||
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
|
||||
@@ -1894,4 +1894,16 @@
|
||||
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
|
||||
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
|
||||
<string name="aiturk_pair_error_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again.</string>
|
||||
<string name="aiturk_pair_error_host_timeout">The computer did not answer. Check that AITURK IDE is running and try again.</string>
|
||||
<string name="aiturk_pair_error_relay_busy">Too many connections. Try again in a moment.</string>
|
||||
<string name="aiturk_pair_error_relay_unknown">This pairing is no longer valid. Pair again from AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_tls_pin">The computer\'s identity could not be verified. Pair again from AITURK IDE.</string>
|
||||
<string name="aiturk_pair_security_title">Connected</string>
|
||||
<string name="aiturk_pair_security_message">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.</string>
|
||||
<string name="aiturk_pair_security_ok">OK</string>
|
||||
<string name="aiturk_pair_error_no_internet">The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again.</string>
|
||||
<string name="aiturk_pair_error_relay">Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil.</string>
|
||||
<string name="aiturk_pair_error_code">Diagnostic code: %1$s</string>
|
||||
<string name="aiturk_status_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off</string>
|
||||
</resources>
|
||||
@@ -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/<relayId>`) 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<String>()
|
||||
val headerNames = CopyOnWriteArrayList<String>()
|
||||
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<Socket>()
|
||||
private val webSockets = CopyOnWriteArrayList<WebSocket>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,111 @@ class AiturkPairingPayloadTest {
|
||||
us: List<String> = listOf("192.168.1.20:47321"),
|
||||
fingerprint: String? = fp,
|
||||
code: String? = "ABCDEFGH",
|
||||
relays: List<String> = emptyList(),
|
||||
): String =
|
||||
buildList {
|
||||
v?.let { add("v=$it") }
|
||||
us.forEach { add("u=$it") }
|
||||
fingerprint?.let { add("fp=$it") }
|
||||
code?.let { add("c=$it") }
|
||||
relays.forEach { add("r=$it") }
|
||||
}.joinToString("&", prefix = "aiturk://pair?")
|
||||
|
||||
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
|
||||
|
||||
// ── Contract v3: relay id ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun relayOnlyPayloadIsTheDefaultAndNeedsNoAddress() {
|
||||
// Exactly what the IDE prints by default: aiturk://pair?v=1&fp=…&c=…&r=<relayId>
|
||||
val result = AiturkPairing.parse("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
|
||||
|
||||
val offer = (result as AiturkPayloadResult.Ok).payload.offer
|
||||
assertEquals(relayId, offer.relay)
|
||||
assertTrue(offer.endpoints.isEmpty())
|
||||
assertEquals(fp, offer.fingerprint)
|
||||
assertEquals("https://$relayId.relay.aiturk.invalid/", AiturkRelay.baseUrl(relayId))
|
||||
assertEquals("$relayId.relay.aiturk.invalid:443", AiturkRelay.hostPort(relayId))
|
||||
assertEquals(relayId, AiturkRelay.relayIdOfHost("$relayId.relay.aiturk.invalid"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayAndLanAddressesCanComeTogether() {
|
||||
val result = AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322"), relays = listOf(relayId)))
|
||||
|
||||
val offer = (result as AiturkPayloadResult.Ok).payload.offer
|
||||
assertEquals(relayId, offer.relay)
|
||||
assertEquals(2, offer.endpoints.size)
|
||||
// Without `r` the offer simply has no relay.
|
||||
assertNull((AiturkPairing.parse(link()) as AiturkPayloadResult.Ok).payload.offer.relay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anythingButALowercaseUuid4RelayIdRejectsThePayload() {
|
||||
val rejected =
|
||||
listOf(
|
||||
// 1. the cancelled v2 form (a URL)
|
||||
"https%3A%2F%2Fturkservis.online%2Fv1%2Faiturk%2Fmobile-relay%2Fr%2F$relayId",
|
||||
// 2. upper case
|
||||
relayId.uppercase(Locale.ROOT),
|
||||
// 3. not version 4
|
||||
"3f2b8c1e-5d4a-1b7c-9e1f-0a1b2c3d4e5f",
|
||||
// 4. wrong variant nibble
|
||||
"3f2b8c1e-5d4a-4b7c-ce1f-0a1b2c3d4e5f",
|
||||
// 5. a host name smuggled in
|
||||
"$relayId.evil.example",
|
||||
// 6. extra path
|
||||
"$relayId%2F..%2Fhost",
|
||||
// 7. surrounding whitespace
|
||||
"%20$relayId",
|
||||
// 8. braces / no dashes
|
||||
"%7B$relayId%7D",
|
||||
relayId.replace("-", ""),
|
||||
// 9. too short / empty-ish
|
||||
relayId.dropLast(1),
|
||||
"x",
|
||||
)
|
||||
rejected.forEach { value ->
|
||||
// The LAN address is valid: a bad `r` must still reject the whole payload.
|
||||
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(value))), value)
|
||||
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(us = emptyList(), relays = listOf(value))), value)
|
||||
assertNull(AiturkRelay.parse(java.net.URLDecoder.decode(value, "UTF-8")), value)
|
||||
}
|
||||
// 10. `r` given twice
|
||||
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(relayId, relayId))))
|
||||
// An empty `r=` is a parameter without a value.
|
||||
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&r"))
|
||||
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link() + "&r="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun payloadWithNeitherAddressNorRelayIsRejected() {
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = emptyList())))
|
||||
val five = (1..5).map { "192.168.1.$it:47321" }
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = five, relays = listOf(relayId))))
|
||||
// A relay does not excuse a bad LAN address.
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf("8.8.8.8:443"), relays = listOf(relayId))))
|
||||
// The fingerprint stays mandatory: it is what makes the tunnel end-to-end.
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf("aiturk://pair?v=1&c=ABCDEFGH&r=$relayId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyExactVirtualHostsAreRelayHosts() {
|
||||
assertTrue(AiturkRelay.isVirtualHost("$relayId.relay.aiturk.invalid"))
|
||||
listOf(
|
||||
null,
|
||||
"",
|
||||
"relay.aiturk.invalid",
|
||||
".relay.aiturk.invalid",
|
||||
"abc.relay.aiturk.invalid",
|
||||
"${relayId.uppercase(Locale.ROOT)}.relay.aiturk.invalid",
|
||||
"$relayId.relay.aiturk.invalid.evil.example",
|
||||
"x.$relayId.relay.aiturk.invalid",
|
||||
"turkservis.online",
|
||||
"192.168.1.20",
|
||||
).forEach { assertFalse(AiturkRelay.isVirtualHost(it), it.toString()) }
|
||||
}
|
||||
|
||||
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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<FakeIde>()
|
||||
|
||||
@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 <T> AiturkPairResult<T>.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)
|
||||
}
|
||||
}
|
||||
@@ -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"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>()
|
||||
ide.extra = { request ->
|
||||
seen.set(request.getHeader("Authorization"))
|
||||
FakeIde.json(200, """{"ok":true}""")
|
||||
}
|
||||
val sniffed = LinkedBlockingQueue<ByteArray>()
|
||||
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<String>()
|
||||
val failure = AtomicReference<Throwable>()
|
||||
|
||||
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<String>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user