feat(aiturk): accept a relay id in pairing payloads

Contract v3 (tunnel): the QR / deep link may carry r=<lowercase UUIDv4>
and no LAN address at all (the IDE's default). u is now 0..4, but a
payload with neither u nor r is rejected, and a present-but-invalid r
rejects the whole payload instead of falling back to the LAN addresses.

AiturkRelay maps a relay id to the virtual host
<relayId>.relay.aiturk.invalid that addresses a tunnel pairing.

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:08:07 +03:00
1 parent 6f4309c746
commit 9afac6f508
2 files changed
+162 -5

No files matched your search

@@ -10,7 +10,11 @@ import java.util.Locale
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
*
* QR / deep-link payload:
* `aiturk://pair?v=1&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
*
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
* sends by default; `u` (0..4) is only present when the user allowed direct
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
*/
data class AiturkEndpoint(
val host: String,
@@ -23,10 +27,14 @@ data class AiturkEndpoint(
val baseUrl: String get() = "https://$host:$port/"
}
/** A pairing offer: where the IDE listens and which certificate it must present. */
/**
* A pairing offer: where the IDE listens and which certificate it must present
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
*/
data class AiturkPairingOffer(
val endpoints: List<AiturkEndpoint>,
val fingerprint: String,
val relay: String? = null,
)
data class AiturkPairingPayload(
@@ -50,9 +58,12 @@ enum class AiturkPayloadError {
/** A `u` entry is not `<private IPv4>:<port>`. */
BAD_ADDRESS,
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
BAD_ADDRESS_COUNT,
/** `r` is present but is not a lowercase UUIDv4 relay id. */
BAD_RELAY,
/** `c` is not 8 characters of the pairing alphabet. */
BAD_CODE,
}
@@ -106,8 +117,18 @@ object AiturkPairing {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
}
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
// never silently dropped in favour of the LAN candidates.
val rawRelays = params.filter { it.first == "r" }.map { it.second }
val relay =
when (rawRelays.size) {
0 -> null
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
}
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
}
val endpoints =
@@ -121,7 +142,7 @@ object AiturkPairing {
return AiturkPayloadResult.Ok(
AiturkPairingPayload(
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
code = code,
),
)
@@ -194,3 +215,42 @@ object AiturkPairing {
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
}
/**
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
* internet; [AiturkTunnel] turns connections to it into an end-to-end
* encrypted stream through the relay.
*/
object AiturkRelay {
const val HOST_SUFFIX = ".relay.aiturk.invalid"
const val PORT = 443
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
private val relayId = Regex(UUID4)
/**
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
* surrounding whitespace.
*/
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
/** Key used by the certificate pin store for a relay pairing. */
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
/** The saved connection base: no path prefix, trailing slash. */
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
/** The relay id behind a virtual host name, or null for any other host. */
fun relayIdOfHost(host: String?): String? =
host
?.takeIf { it.endsWith(HOST_SUFFIX) }
?.removeSuffix(HOST_SUFFIX)
?.let(::parse)
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
}