feat(aiturk): reach the IDE through an end-to-end encrypted tunnel

A phone on the same Wi-Fi could not reach the computer at all (client
isolation on the router), and every failure was shown as one 'same
Wi-Fi' message. Contract v3: the phone only dials turkservis.online.

Transport: a relay pairing lives on the virtual host
<relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp
client resolves a placeholder address, opens a WebSocket byte stream to
the relay instead of a TCP connection, and runs TLS on top of it pinned
to the IDE's certificate fingerprint. The relay sees ciphertext only.
DNS, sockets, proxy choice and TLS are unchanged for every other host.

Pairing: tunnel first (hello within 10 s), LAN candidates second, one
claim only. The local-network permission is requested only when a LAN
address may be tried; denying it no longer ends the pairing.

Errors: host offline, unknown relay, busy, no internet, silent computer,
certificate mismatch and the LAN failures each get their own text plus
a short diagnostic code built from a fixed vocabulary (never a token,
pairing code or address). After pairing the screen shows the first 8
characters of the fingerprint as a security code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-05 04:18:52 +03:00
1 parent 9afac6f508
commit cbe716300c
14 files changed
+2469 -138

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
}
}
}
}
}
@@ -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
}
}
@@ -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