app: scaffold echolot-app + core-protocol — client spine verified live vs fmr
Multi-module Android app, built bottom-up from a verifiable core. core-protocol is pure Kotlin/JVM (no Android SDK): SPKI-pinned control plane (enroll/profile/session over HttpsURLConnection — API-1 compatible, hostname verification off, trust is the pin), HKDF-SHA256 session keys, ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) — byte-compatible with the Go server. Unit tests incl. the RFC 5869 HKDF vector (key derivation provably matches the server). LiveServerTest + scripts/test-fmr.sh prove the client end-to-end against the deployed fmr server: profile (8 caps), session, ECHO rtt~11ms with the observation block returning our observed NAT port, MTU 1400->1400, observations. Live test self-skips without ECHOLOT_LIVE_*. Two client bugs caught live: java.net.http hostname verification (→ HttpsURLConnection, also the Android-minSdk-26 choice) and ECHO padding needed for the observation to survive anti-amplification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b229eeb674
commit
3520eabd21
@@ -299,3 +299,18 @@ Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp-
|
||||
delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns.
|
||||
Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT
|
||||
retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI.
|
||||
|
||||
## Production app — echolot-app/, core-protocol proven live (2026-07-31)
|
||||
Started the Android client, bottom-up from the verifiable spine. `echolot-app/` is a multi-module
|
||||
Gradle build; `core-protocol` is a **pure Kotlin/JVM** module (no Android SDK) implementing the
|
||||
client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via
|
||||
HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin),
|
||||
HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
|
||||
byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key
|
||||
derivation provably matches the server). **Verified END-TO-END against live fmr** via
|
||||
`scripts/test-fmr.sh` (mint token over SSH → enroll on public control plane → run LiveServerTest):
|
||||
profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's
|
||||
observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it:
|
||||
java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte
|
||||
requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement
|
||||
(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
/local.properties
|
||||
/.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -0,0 +1,35 @@
|
||||
# Echolot app
|
||||
|
||||
The production Android client ([spec](../docs/)). Native Kotlin + Jetpack Compose. Multi-module;
|
||||
built bottom-up from a verifiable protocol spine.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Type | Status |
|
||||
|---|---|---|
|
||||
| `core-protocol` | pure Kotlin/JVM | **done** — client half of `probe-protocol.md`, verified live against the server |
|
||||
| `core-measurement` | pure Kotlin/JVM | planned — `measurement-schema.md` types |
|
||||
| `core-probe` | Android lib | planned — app-tier probes, ported from `echolot-prober` |
|
||||
| `core-shizuku` | Android lib | planned — dual-path executor (UserService + newProcess fallback) |
|
||||
| `app` | Android app | planned — Compose UI |
|
||||
|
||||
`core-protocol` is deliberately Android-free so it builds and unit-tests on any JDK (no Android
|
||||
SDK) and can run **integration tests against a live server**.
|
||||
|
||||
## core-protocol
|
||||
|
||||
Implements the control plane (SPKI-pinned enrollment/profile/sessions via `HttpsURLConnection` —
|
||||
Android-API-1 compatible, hostname verification off because trust is the pin), the HKDF-SHA256
|
||||
session-key schedule, and the binary ELT1 UDP data plane (HMAC gate, ECHO + observation block,
|
||||
MTU probe) — byte-compatible with the Go server.
|
||||
|
||||
```sh
|
||||
./gradlew :core-protocol:test # unit tests (crypto vectors, wire round-trip)
|
||||
scripts/test-fmr.sh # live end-to-end test against the deployed server
|
||||
```
|
||||
|
||||
`test-fmr.sh` mints an enrollment token over SSH, enrolls via the public control plane, computes
|
||||
the SPKI pin from the served cert, and runs `LiveServerTest` — proving the client speaks the wire
|
||||
protocol to the real server (enroll → profile → session → echo+observation → MTU → observations).
|
||||
The live test self-skips when `ECHOLOT_LIVE_*` env vars are absent, so unit runs and CI stay green
|
||||
offline.
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
|
||||
// the Android app modules can depend on it and it stays unit-testable (incl.
|
||||
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
|
||||
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// Build with the available JDK (Android Studio's JBR is 21) but emit
|
||||
// Java-17 bytecode so the Android app modules can consume this library.
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
// The live end-to-end test against a real server only runs when
|
||||
// ECHOLOT_LIVE_URL is set; otherwise it self-skips (see LiveServerTest).
|
||||
listOf("ECHOLOT_LIVE_URL", "ECHOLOT_LIVE_PIN", "ECHOLOT_LIVE_CRED",
|
||||
"ECHOLOT_LIVE_UDP", "ECHOLOT_LIVE_TARGET").forEach { k ->
|
||||
System.getenv(k)?.let { environment(k, it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/**
|
||||
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
|
||||
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
|
||||
* java.net.http.HttpClient which needs API 34) with a pin-based SSLSocketFactory and hostname
|
||||
* verification DISABLED: trust is the SPKI pin, never the certificate name (self-signed servers
|
||||
* with no SAN are first-class). Blocking; the Android layer wraps calls in coroutines.
|
||||
*
|
||||
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
|
||||
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
|
||||
*/
|
||||
class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val socketFactory = Pinning.sslContext(pins).socketFactory
|
||||
|
||||
private fun open(path: String, method: String, credential: String?): HttpsURLConnection {
|
||||
val conn = URL(controlUrl.trimEnd('/') + path).openConnection() as HttpsURLConnection
|
||||
conn.sslSocketFactory = socketFactory
|
||||
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 10_000
|
||||
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
return conn
|
||||
}
|
||||
|
||||
private fun body(conn: HttpsURLConnection): String {
|
||||
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
|
||||
return stream?.bufferedReader()?.use { it.readText() } ?: ""
|
||||
}
|
||||
|
||||
private fun writeJson(conn: HttpsURLConnection, payload: String) {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.outputStream.use { it.write(payload.toByteArray()) }
|
||||
}
|
||||
|
||||
// Minimal JSON string literal (the only bodies we send are one short field).
|
||||
private fun jstr(s: String): String {
|
||||
val sb = StringBuilder("\"")
|
||||
for (c in s) when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
else -> sb.append(c)
|
||||
}
|
||||
return sb.append('"').toString()
|
||||
}
|
||||
|
||||
/** Redeem a single-use enrollment token for a device credential (§2.1). */
|
||||
fun enroll(token: String, name: String? = null): EnrollResponse {
|
||||
val conn = open("/v1/enroll", "POST", null)
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
|
||||
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
|
||||
}
|
||||
|
||||
fun profile(credential: String): Profile {
|
||||
val conn = open("/v1/profile", "GET", credential)
|
||||
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(Profile.serializer(), body(conn))
|
||||
}
|
||||
|
||||
fun createSession(credential: String, target: String): SessionResponse {
|
||||
val conn = open("/v1/sessions", "POST", credential)
|
||||
writeJson(conn, """{"target":${jstr(target)}}""")
|
||||
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
|
||||
return json.decodeFromString(SessionResponse.serializer(), body(conn))
|
||||
}
|
||||
|
||||
fun observations(credential: String, sessionId: String): String {
|
||||
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
}
|
||||
|
||||
fun deleteSession(credential: String, sessionId: String) {
|
||||
open("/v1/sessions/$sessionId", "DELETE", credential).responseCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* The protocol crypto primitives, matching the server exactly (probe-protocol.md §2.4/§3.1):
|
||||
* HMAC-SHA256 for the data-plane gate, and HKDF-SHA256 for the session key
|
||||
* `HKDF(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id)`.
|
||||
* JDK-only (javax.crypto) — no third-party crypto.
|
||||
*/
|
||||
object Crypto {
|
||||
|
||||
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
|
||||
Mac.getInstance("HmacSHA256").run {
|
||||
init(SecretKeySpec(key, "HmacSHA256"))
|
||||
doFinal(data)
|
||||
}
|
||||
|
||||
/** First 4 bytes of HMAC-SHA256 — the wire anti-abuse gate (spec §3.1). */
|
||||
fun hmac32(key: ByteArray, data: ByteArray): ByteArray = hmacSha256(key, data).copyOf(4)
|
||||
|
||||
/**
|
||||
* HKDF-SHA256 (RFC 5869) extract-then-expand. The JDK exposes no HKDF, so it is built from
|
||||
* HMAC — small and standard.
|
||||
*/
|
||||
fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||
val prk = hmacSha256(if (salt.isEmpty()) ByteArray(32) else salt, ikm) // extract
|
||||
val out = ByteArray(length)
|
||||
var t = ByteArray(0)
|
||||
var pos = 0
|
||||
var counter = 1
|
||||
while (pos < length) {
|
||||
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(prk, "HmacSHA256")) }
|
||||
mac.update(t)
|
||||
mac.update(info)
|
||||
mac.update(counter.toByte())
|
||||
t = mac.doFinal()
|
||||
val n = minOf(t.size, length - pos)
|
||||
t.copyInto(out, pos, 0, n)
|
||||
pos += n
|
||||
counter++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Derives the 32-byte session key for a session (spec §2.4). */
|
||||
fun sessionKey(credential: String, keySalt: ByteArray, sessionId: String): ByteArray =
|
||||
hkdfSha256(credential.toByteArray(), keySalt, "echolot-v1/$sessionId".toByteArray(), 32)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/** Control-plane JSON shapes (probe-protocol.md §2). Only fields the client uses are modeled;
|
||||
* unknown fields are ignored by the lenient Json in [ControlClient]. */
|
||||
|
||||
@Serializable
|
||||
data class EnrollResponse(
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
val credential: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Target(
|
||||
val id: String,
|
||||
val ip4: String? = null,
|
||||
val ip6: String? = null,
|
||||
@SerialName("udp_port") val udpPort: Int = 0,
|
||||
@SerialName("tcp_port") val tcpPort: Int = 0,
|
||||
@SerialName("stun_port") val stunPort: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SelfTest(
|
||||
@SerialName("mtu_ok") val mtuOk: Boolean? = null,
|
||||
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@SerialName("profile_version") val profileVersion: Int = 0,
|
||||
val name: String = "",
|
||||
@SerialName("server_version") val serverVersion: String = "",
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val targets: List<Target> = emptyList(),
|
||||
@SerialName("canary_zone") val canaryZone: String = "",
|
||||
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
|
||||
val pins: List<String> = emptyList(),
|
||||
) {
|
||||
fun supports(capability: String) = capability in capabilities
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SessionResponse(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("key_salt") val keySalt: String, // base64
|
||||
val epoch: String,
|
||||
@SerialName("expires_s") val expiresS: Int,
|
||||
)
|
||||
|
||||
/** Observations bundle (§6). Kept as raw JSON where the shape is still evolving server-side. */
|
||||
@Serializable
|
||||
data class Observations(
|
||||
val udp: JsonElement? = null,
|
||||
val tcp: JsonElement? = null,
|
||||
@SerialName("connect_back") val connectBack: JsonElement? = null,
|
||||
@SerialName("dns_canary") val dnsCanary: JsonElement? = null,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* SPKI-pinned trust (probe-protocol.md §1): the client trusts the server ONLY against the
|
||||
* `pin-sha256` from enrollment — CA validation is not required and self-signed is first-class.
|
||||
* The pin is base64(SHA-256(SubjectPublicKeyInfo)), RFC 7469.
|
||||
*/
|
||||
object Pinning {
|
||||
|
||||
fun spkiPin(cert: X509Certificate): String {
|
||||
val spki = cert.publicKey.encoded // DER SubjectPublicKeyInfo
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(spki)
|
||||
return java.util.Base64.getEncoder().encodeToString(digest)
|
||||
}
|
||||
|
||||
/** An SSLContext that accepts a chain iff its leaf SPKI matches one of the expected pins. */
|
||||
fun sslContext(expectedPins: Set<String>): SSLContext {
|
||||
val tm = object : X509TrustManager {
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
|
||||
val leaf = chain.firstOrNull() ?: throw java.security.cert.CertificateException("empty chain")
|
||||
val pin = spkiPin(leaf)
|
||||
if (pin !in expectedPins) {
|
||||
throw java.security.cert.CertificateException("SPKI pin mismatch: got $pin")
|
||||
}
|
||||
}
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) = Unit
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
return SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1
|
||||
* packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
|
||||
* server target. Blocking; the caller owns threading.
|
||||
*/
|
||||
class ProbeSession(
|
||||
private val credential: String,
|
||||
private val session: SessionResponse,
|
||||
private val serverHost: String,
|
||||
private val serverUdpPort: Int,
|
||||
) : AutoCloseable {
|
||||
|
||||
private val key: ByteArray =
|
||||
Crypto.sessionKey(credential, Base64.getDecoder().decode(session.keySalt), session.sessionId)
|
||||
private val prefix: ByteArray = Wire.wirePrefix(session.sessionId)
|
||||
private val epochNanos = System.nanoTime()
|
||||
private val socket = DatagramSocket().apply { soTimeout = 3000 }
|
||||
private val server = InetSocketAddress(serverHost, serverUdpPort)
|
||||
private var seq = 0
|
||||
|
||||
private fun nowNs() = System.nanoTime() - epochNanos
|
||||
|
||||
/**
|
||||
* One ECHO round trip. Returns RTT in ms and the server's observation, or null on loss.
|
||||
*
|
||||
* The response is capped at the request size (§3.4 anti-amplification) and the observation
|
||||
* block is 40 bytes, so the request must be at least header+40 = 72 bytes for the full
|
||||
* observation to fit — hence the ≥40 default padding. Smaller requests still measure RTT.
|
||||
*/
|
||||
fun echo(paddingBytes: Int = 40): EchoResult? {
|
||||
val t0 = System.nanoTime()
|
||||
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, ++seq, nowNs(), key, ByteArray(paddingBytes))
|
||||
socket.send(DatagramPacket(pkt, pkt.size, server))
|
||||
val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null
|
||||
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||
return EchoResult(rttMs, Observation.parse(resp.payload))
|
||||
}
|
||||
|
||||
/** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported).
|
||||
* Returns the size the server acknowledged receiving, or null if the probe was lost. */
|
||||
fun mtuProbe(totalSize: Int): Int? {
|
||||
val payloadLen = (totalSize - Wire.HEADER_SIZE).coerceAtLeast(0)
|
||||
val pkt = Wire.build(Wire.TYPE_MTU_PROBE, prefix, ++seq, nowNs(), key, ByteArray(payloadLen))
|
||||
socket.send(DatagramPacket(pkt, pkt.size, server))
|
||||
val resp = receive(Wire.TYPE_MTU_ACK) ?: return null
|
||||
if (resp.payload.size < 4) return null
|
||||
return ((resp.payload[0].toInt() and 0xFF) shl 24) or
|
||||
((resp.payload[1].toInt() and 0xFF) shl 16) or
|
||||
((resp.payload[2].toInt() and 0xFF) shl 8) or
|
||||
(resp.payload[3].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun receive(wantType: Int): Wire.Packet? {
|
||||
val buf = ByteArray(2048)
|
||||
return try {
|
||||
val dp = DatagramPacket(buf, buf.size)
|
||||
socket.receive(dp)
|
||||
Wire.parseVerified(buf, dp.length, key)?.takeIf { it.type == wantType }
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = socket.close()
|
||||
|
||||
data class EchoResult(val rttMs: Double, val observation: Observation?)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* The binary UDP probe protocol wire format (probe-protocol.md §3.1): a fixed 32-byte header
|
||||
* plus payload, HMAC-gated. Mirrors the Go server's dataplane package byte-for-byte.
|
||||
*
|
||||
* ```
|
||||
* 0 4 magic "ELT1" 8 8 session_prefix (first 8 bytes of session id)
|
||||
* 4 1 type 16 4 seq
|
||||
* 5 1 flags 20 8 t_ns (sender clock, ns since session epoch)
|
||||
* 6 2 payload_len 28 4 hmac32(session_key, header[0..28] || payload)
|
||||
* ```
|
||||
*/
|
||||
object Wire {
|
||||
const val HEADER_SIZE = 32
|
||||
val MAGIC = byteArrayOf('E'.code.toByte(), 'L'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte())
|
||||
|
||||
const val TYPE_ECHO_REQ: Int = 0x01
|
||||
const val TYPE_ECHO_RESP: Int = 0x02
|
||||
const val TYPE_TIMESYNC_REQ: Int = 0x07
|
||||
const val TYPE_TIMESYNC_RSP: Int = 0x08
|
||||
const val TYPE_MTU_PROBE: Int = 0x09
|
||||
const val TYPE_MTU_ACK: Int = 0x0A
|
||||
const val TYPE_DELAYED_ECHO: Int = 0x0B
|
||||
|
||||
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
|
||||
fun wirePrefix(sessionId: String): ByteArray {
|
||||
require(sessionId.length >= 16) { "session id too short" }
|
||||
val p = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
p[i] = ((hex(sessionId[i * 2]) shl 4) or hex(sessionId[i * 2 + 1])).toByte()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
private fun hex(c: Char): Int = when (c) {
|
||||
in '0'..'9' -> c - '0'
|
||||
in 'a'..'f' -> c - 'a' + 10
|
||||
in 'A'..'F' -> c - 'A' + 10
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** Builds a signed packet ready to send. */
|
||||
fun build(
|
||||
type: Int, sessionPrefix: ByteArray, seq: Int, tNs: Long, key: ByteArray,
|
||||
payload: ByteArray = ByteArray(0),
|
||||
): ByteArray {
|
||||
val buf = ByteBuffer.allocate(HEADER_SIZE + payload.size).order(ByteOrder.BIG_ENDIAN)
|
||||
buf.put(MAGIC)
|
||||
buf.put(type.toByte())
|
||||
buf.put(0) // flags
|
||||
buf.putShort(payload.size.toShort())
|
||||
buf.put(sessionPrefix, 0, 8)
|
||||
buf.putInt(seq)
|
||||
buf.putLong(tNs)
|
||||
buf.position(28) // leave hmac slot; fill after
|
||||
buf.putInt(0)
|
||||
buf.put(payload)
|
||||
val bytes = buf.array()
|
||||
// HMAC over header[0..28] || payload (the hmac slot itself excluded).
|
||||
val mac = Crypto.hmacSha256(key, concat(bytes, 0, 28, bytes, HEADER_SIZE, payload.size))
|
||||
mac.copyInto(bytes, 28, 0, 4)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** A parsed, HMAC-verified inbound packet. */
|
||||
data class Packet(val type: Int, val seq: Int, val tNs: Long, val payload: ByteArray)
|
||||
|
||||
/** Parses and verifies an inbound datagram; null if malformed or the HMAC fails. */
|
||||
fun parseVerified(data: ByteArray, len: Int, key: ByteArray): Packet? {
|
||||
if (len < HEADER_SIZE) return null
|
||||
for (i in MAGIC.indices) if (data[i] != MAGIC[i]) return null
|
||||
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
|
||||
val type = bb.get(4).toInt() and 0xFF
|
||||
val payloadLen = bb.getShort(6).toInt() and 0xFFFF
|
||||
if (HEADER_SIZE + payloadLen > len) return null
|
||||
val expect = Crypto.hmacSha256(key, concat(data, 0, 28, data, HEADER_SIZE, payloadLen))
|
||||
for (i in 0 until 4) if (expect[i] != data[28 + i]) return null
|
||||
val seq = bb.getInt(16)
|
||||
val tNs = bb.getLong(20)
|
||||
val payload = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadLen)
|
||||
return Packet(type, seq, tNs, payload)
|
||||
}
|
||||
|
||||
private fun concat(a: ByteArray, aOff: Int, aLen: Int, b: ByteArray, bOff: Int, bLen: Int): ByteArray {
|
||||
val out = ByteArray(aLen + bLen)
|
||||
a.copyInto(out, 0, aOff, aOff + aLen)
|
||||
b.copyInto(out, aLen, bOff, bOff + bLen)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/** Server observation block appended to ECHO_RESP (spec §3.3), fixed 40 bytes. */
|
||||
data class Observation(
|
||||
val tRxNs: Long, val tTxNs: Long, val observedPort: Int, val receivedSize: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun parse(payload: ByteArray): Observation? {
|
||||
if (payload.size < 40) return null
|
||||
val bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN)
|
||||
return Observation(
|
||||
tRxNs = bb.getLong(0),
|
||||
tTxNs = bb.getLong(8),
|
||||
observedPort = bb.getShort(32).toInt() and 0xFFFF,
|
||||
receivedSize = bb.getInt(36),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CryptoWireTest {
|
||||
|
||||
@Test
|
||||
fun hkdfMatchesRfc5869Vector() {
|
||||
// RFC 5869 Appendix A.1 (SHA-256).
|
||||
val ikm = ByteArray(22) { 0x0b }
|
||||
val salt = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
|
||||
val info = byteArrayOf(
|
||||
0xf0.toByte(), 0xf1.toByte(), 0xf2.toByte(), 0xf3.toByte(), 0xf4.toByte(),
|
||||
0xf5.toByte(), 0xf6.toByte(), 0xf7.toByte(), 0xf8.toByte(), 0xf9.toByte(),
|
||||
)
|
||||
val okm = Crypto.hkdfSha256(ikm, salt, info, 42)
|
||||
val expect = "3cb25f25faacd57a90434f64d0362f2a" +
|
||||
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
|
||||
"34007208d5b887185865"
|
||||
assertEquals(expect, okm.joinToString("") { "%02x".format(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wirePrefixDecodesHex() {
|
||||
val prefix = Wire.wirePrefix("805a43f8395ae08ace7a14803766cb11")
|
||||
assertEquals("805a43f8395ae08a", prefix.joinToString("") { "%02x".format(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildThenParseRoundTripsAndVerifies() {
|
||||
val key = ByteArray(32) { it.toByte() }
|
||||
val prefix = ByteArray(8) { (it + 1).toByte() }
|
||||
val payload = "hello-echolot".toByteArray()
|
||||
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, 7, 123_456L, key, payload)
|
||||
assertEquals(Wire.HEADER_SIZE + payload.size, pkt.size)
|
||||
|
||||
val parsed = Wire.parseVerified(pkt, pkt.size, key)
|
||||
assertNotNull(parsed)
|
||||
assertEquals(Wire.TYPE_ECHO_REQ, parsed.type)
|
||||
assertEquals(7, parsed.seq)
|
||||
assertEquals(123_456L, parsed.tNs)
|
||||
assertEquals("hello-echolot", String(parsed.payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedHmacIsRejected() {
|
||||
val key = ByteArray(32) { it.toByte() }
|
||||
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, key, ByteArray(4))
|
||||
pkt[pkt.size - 1] = (pkt[pkt.size - 1].toInt() xor 0xFF).toByte() // flip a payload byte
|
||||
assertNull(Wire.parseVerified(pkt, pkt.size, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongKeyIsRejected() {
|
||||
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, ByteArray(32) { 1 }, ByteArray(0))
|
||||
assertNull(Wire.parseVerified(pkt, pkt.size, ByteArray(32) { 2 }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observationParses() {
|
||||
// 40-byte block: t_rx, t_tx, 16-byte addr, port, ttl/dscp, size.
|
||||
val b = ByteArray(40)
|
||||
b[33] = 0x1F // port low byte = 8191... set port bytes 32..33
|
||||
b[32] = 0x00
|
||||
val obs = Observation.parse(b)
|
||||
assertNotNull(obs)
|
||||
assertTrue(obs.observedPort in 0..65535)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* End-to-end test of the Kotlin client against a REAL running server. It self-skips unless the
|
||||
* environment provides a live target, so it never breaks CI (no network / no server):
|
||||
*
|
||||
* ECHOLOT_LIVE_URL = https://fmr-1.echo-lot.app:8443
|
||||
* ECHOLOT_LIVE_PIN = <base64 pin-sha256>
|
||||
* ECHOLOT_LIVE_CRED = <device credential from an enrollment>
|
||||
* ECHOLOT_LIVE_UDP = fmr-1.echo-lot.app:8442
|
||||
* ECHOLOT_LIVE_TARGET = fmr (profile target id)
|
||||
*
|
||||
* The harness (test-fmr.sh) mints a token over SSH, enrolls via the public control plane, and
|
||||
* exports these — proving the client talks to the deployed server over the wire.
|
||||
*/
|
||||
class LiveServerTest {
|
||||
|
||||
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||
|
||||
@Test
|
||||
fun fullFlowAgainstLiveServer() {
|
||||
if (url == null || pin == null || cred == null || udp == null) {
|
||||
println("LiveServerTest skipped (no ECHOLOT_LIVE_* env)")
|
||||
return
|
||||
}
|
||||
val control = ControlClient(url, setOf(pin))
|
||||
|
||||
val profile = control.profile(cred)
|
||||
println("profile: name=${profile.name} v=${profile.serverVersion} caps=${profile.capabilities}")
|
||||
assertTrue(profile.supports("udp-probe"), "server must offer udp-probe")
|
||||
|
||||
val session = control.createSession(cred, target)
|
||||
println("session: ${session.sessionId} expires=${session.expiresS}s")
|
||||
|
||||
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||
ProbeSession(cred, session, host, port).use { ps ->
|
||||
// ECHO: verified response + observation with our observed port.
|
||||
val echo = ps.echo(paddingBytes = 64) // ≥40 so the observation block fits (§3.4)
|
||||
assertNotNull(echo, "no verified ECHO_RESP from live server")
|
||||
println("echo rtt=${"%.1f".format(echo.rttMs)}ms observedPort=${echo.observation?.observedPort} size=${echo.observation?.receivedSize}")
|
||||
assertNotNull(echo.observation, "ECHO_RESP missing observation block")
|
||||
|
||||
// MTU probe: server acks the size it received.
|
||||
val acked = ps.mtuProbe(1400)
|
||||
assertNotNull(acked, "no MTU_ACK from live server")
|
||||
println("mtu probe 1400 -> server received $acked bytes")
|
||||
assertTrue(acked!! in 1300..1500, "acked size implausible: $acked")
|
||||
}
|
||||
|
||||
val obs = control.observations(cred, session.sessionId)
|
||||
println("observations bytes: ${obs.length}")
|
||||
assertTrue(obs.contains("packets_seen"), "observations should report packets_seen")
|
||||
control.deleteSession(cred, session.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[versions]
|
||||
kotlin = "2.2.10"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
|
||||
[libraries]
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Runs core-protocol's LiveServerTest against the deployed fmr server: mints an
|
||||
# enrollment token over SSH (admin is localhost-only), enrolls over the public
|
||||
# control plane, computes the SPKI pin from the served cert, and hands the
|
||||
# whole lot to the Gradle test. Proves the Kotlin client talks to the real
|
||||
# server over the wire.
|
||||
#
|
||||
# Usage: JAVA_HOME=... echolot-app/scripts/test-fmr.sh
|
||||
set -euo pipefail
|
||||
|
||||
SSH_HOST="${ECHOLOT_SSH:-claude-echolot}"
|
||||
CTL_HOST="${ECHOLOT_CTL_HOST:-fmr-1.echo-lot.app}"
|
||||
CTL_PORT="${ECHOLOT_CTL_PORT:-8443}"
|
||||
UDP_PORT="${ECHOLOT_UDP_PORT:-8442}"
|
||||
CTL_URL="https://${CTL_HOST}:${CTL_PORT}"
|
||||
|
||||
echo "· minting enrollment token on ${SSH_HOST} ..."
|
||||
TOKEN=$(ssh -o BatchMode=yes "$SSH_HOST" \
|
||||
'curl -s -X POST http://127.0.0.1:8444/admin/enroll-tokens' \
|
||||
| python -c 'import json,sys;print(json.load(sys.stdin)["token"])')
|
||||
|
||||
echo "· enrolling over ${CTL_URL} ..."
|
||||
CRED=$(curl -sk -X POST "${CTL_URL}/v1/enroll" -H "Authorization: Bearer ${TOKEN}" \
|
||||
| python -c 'import json,sys;print(json.load(sys.stdin)["credential"])')
|
||||
|
||||
echo "· computing SPKI pin from served cert ..."
|
||||
PIN=$(echo | openssl s_client -connect "${CTL_HOST}:${CTL_PORT}" 2>/dev/null \
|
||||
| openssl x509 -pubkey -noout \
|
||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||
| openssl dgst -sha256 -binary | openssl base64)
|
||||
|
||||
echo "· pin=${PIN}"
|
||||
echo "· running LiveServerTest ..."
|
||||
cd "$(dirname "$0")/.."
|
||||
ECHOLOT_LIVE_URL="$CTL_URL" \
|
||||
ECHOLOT_LIVE_PIN="$PIN" \
|
||||
ECHOLOT_LIVE_CRED="$CRED" \
|
||||
ECHOLOT_LIVE_UDP="${CTL_HOST}:${UDP_PORT}" \
|
||||
ECHOLOT_LIVE_TARGET="${ECHOLOT_LIVE_TARGET:-fmr}" \
|
||||
./gradlew :core-protocol:test --tests '*LiveServerTest*' --info --rerun-tasks --console=plain \
|
||||
2>&1 | grep -E "profile:|session:|echo |mtu probe|observations bytes|LiveServerTest|BUILD|FAIL|PASS" || true
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "echolot-app"
|
||||
|
||||
// core-protocol is a pure Kotlin/JVM module (the client side of
|
||||
// probe-protocol.md) so it builds and unit-tests without the Android SDK and
|
||||
// can run integration tests against a live server. Android modules
|
||||
// (core-probe, core-shizuku, app) join as they land.
|
||||
include(":core-protocol")
|
||||
Reference in New Issue
Block a user