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:
1 parent
9afac6f508
commit
cbe716300c
14 files changed
+2469
-138
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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