app: dns.canary probe — client half of the canary measurement, verified live
Resolves the server's canary zone through the platform resolver and compares against the spec-frozen ground truth (probe-protocol §6.1): reference records detect answers rewritten in flight, and a per-run nonce name (uncacheable) proves the query reached the authoritative server. Findings: dns.answer_rewritten (high), dns.authoritative_unreachable (medium). Verified on the OnePlus against the deployed fmr zone: 4/4 reference records matched exactly, nonce name answered 192.0.2.21 with reached_authoritative=true. First full client<->server measurement loop on real hardware; report archived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf5cd2dc68
commit
59ba1c16bc
@@ -12,6 +12,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.echo_lot.measurement.*
|
||||
import app.echo_lot.probe.CaptivePortalProbe
|
||||
import app.echo_lot.probe.DnsCanaryProbe
|
||||
import app.echo_lot.probe.IcmpProbe
|
||||
import app.echo_lot.probe.LinkSnapshotProbe
|
||||
import app.echo_lot.probe.NetworkInventory
|
||||
@@ -71,6 +72,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
IcmpProbe(entries, v6 = false),
|
||||
IcmpProbe(entries, v6 = true),
|
||||
CaptivePortalProbe(entries),
|
||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||
// the reference deployment until profiles/enrollment land in the UI.
|
||||
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
|
||||
)
|
||||
|
||||
val tests = ArrayList<Test>()
|
||||
@@ -157,6 +161,30 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (t.type == TestType.DNS_CANARY) {
|
||||
val ev = t.evidence?.toString() ?: ""
|
||||
if (ev.contains("MISMATCH")) {
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS,
|
||||
severity = Severity.HIGH, confidence = Confidence.HIGH,
|
||||
title = "DNS answers are being rewritten",
|
||||
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
)
|
||||
} else if (ev.contains("\"reached_authoritative\":false")) {
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS,
|
||||
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM,
|
||||
title = "Canary queries don't reach the authoritative server",
|
||||
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||
out.add(
|
||||
Finding(
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package engine composes core-protocol probes into core-measurement documents — the run engine
|
||||
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
|
||||
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.*
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.ProbeSession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
|
||||
/**
|
||||
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
|
||||
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
|
||||
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
|
||||
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
|
||||
* (no clocks/UUIDs of its own) and unit-testable.
|
||||
*/
|
||||
class ServerMeasurement(
|
||||
private val ids: IdSource,
|
||||
private val app: AppInfo,
|
||||
private val device: DeviceInfo,
|
||||
) {
|
||||
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||
|
||||
data class Config(
|
||||
val controlUrl: String,
|
||||
val pins: Set<String>,
|
||||
val credential: String,
|
||||
val target: String,
|
||||
val udpHost: String,
|
||||
val udpPort: Int,
|
||||
val echoCount: Int = 20,
|
||||
val echoPaddingBytes: Int = 64,
|
||||
)
|
||||
|
||||
fun run(cfg: Config): MeasurementDocument {
|
||||
val runId = ids.uuid()
|
||||
val startWall = ids.nowWall()
|
||||
val startMono = ids.monoNs()
|
||||
|
||||
val control = ControlClient(cfg.controlUrl, cfg.pins)
|
||||
val profile = control.profile(cfg.credential)
|
||||
val session = control.createSession(cfg.credential, cfg.target)
|
||||
|
||||
val serverSession = ServerSession(
|
||||
id = "sess-1",
|
||||
profileName = profile.name,
|
||||
controlUrl = cfg.controlUrl,
|
||||
serverVersion = profile.serverVersion,
|
||||
capabilities = profile.capabilities,
|
||||
sessionId = session.sessionId,
|
||||
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
|
||||
)
|
||||
|
||||
val (test, findings) = echoTrain(cfg, control, session, startMono)
|
||||
|
||||
control.deleteSession(cfg.credential, session.sessionId)
|
||||
|
||||
val summary = Verdicts.derive(listOf(test), findings)
|
||||
return MeasurementDocument(
|
||||
run = Run(
|
||||
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
|
||||
clock = Clock(monoOriginWall = startWall),
|
||||
app = app, device = device,
|
||||
tiers = Tiers(app = true),
|
||||
),
|
||||
serverSessions = listOf(serverSession),
|
||||
tests = listOf(test),
|
||||
findings = findings,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun echoTrain(
|
||||
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
|
||||
): Pair<Test, List<Finding>> {
|
||||
val testId = ids.uuid()
|
||||
val seqs = ArrayList<Int>()
|
||||
val tTx = ArrayList<Long?>()
|
||||
val tRx = ArrayList<Long?>()
|
||||
val sizes = ArrayList<Int>()
|
||||
val rtts = ArrayList<Double>()
|
||||
val observedPorts = LinkedHashSet<Int>()
|
||||
|
||||
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
|
||||
for (i in 0 until cfg.echoCount) {
|
||||
val txMono = ids.monoNs() - startMono
|
||||
val r = ps.echo(cfg.echoPaddingBytes)
|
||||
seqs.add(i)
|
||||
tTx.add(txMono)
|
||||
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
|
||||
if (r != null) {
|
||||
tRx.add(ids.monoNs() - startMono)
|
||||
rtts.add(r.rttMs)
|
||||
r.observation?.observedPort?.let { observedPorts.add(it) }
|
||||
} else {
|
||||
tRx.add(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sent = cfg.echoCount
|
||||
val received = rtts.size
|
||||
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||
val natRebinding = observedPorts.size > 1
|
||||
|
||||
val evidence: JsonObject = TrainEvidence(
|
||||
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
|
||||
).toEvidence()
|
||||
|
||||
val metrics: JsonObject = json.encodeToJsonElement(
|
||||
EchoMetrics(
|
||||
sent = sent, received = received, lossPct = round1(lossPct),
|
||||
rttMsMin = rtts.minOrNull()?.let(::round1),
|
||||
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
|
||||
rttMsMax = rtts.maxOrNull()?.let(::round1),
|
||||
observedPorts = observedPorts.toList(),
|
||||
natRebindingDetected = natRebinding,
|
||||
)
|
||||
) as JsonObject
|
||||
|
||||
val status = when {
|
||||
received == 0 -> TestStatus.FAILED
|
||||
received < sent -> TestStatus.PARTIAL
|
||||
else -> TestStatus.OK
|
||||
}
|
||||
val test = Test(
|
||||
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
|
||||
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
|
||||
evidence = evidence, metrics = metrics,
|
||||
)
|
||||
|
||||
val findings = ArrayList<Finding>()
|
||||
if (received == 0) {
|
||||
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
|
||||
"No UDP echo replies from the server",
|
||||
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
|
||||
} else if (lossPct >= 20.0) {
|
||||
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
|
||||
"High UDP loss to the server (${round1(lossPct)}%)",
|
||||
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
|
||||
}
|
||||
if (natRebinding) {
|
||||
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
|
||||
"NAT remapped the UDP source port mid-flow",
|
||||
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
|
||||
}
|
||||
return test to findings
|
||||
}
|
||||
|
||||
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
|
||||
Finding(
|
||||
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val Wire_HEADER = 32
|
||||
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
|
||||
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
|
||||
*/
|
||||
interface IdSource {
|
||||
fun uuid(): String
|
||||
fun monoNs(): Long
|
||||
fun nowWall(): String
|
||||
}
|
||||
|
||||
class SystemIdSource : IdSource {
|
||||
override fun uuid(): String = UUID.randomUUID().toString()
|
||||
override fun monoNs(): Long = System.nanoTime()
|
||||
override fun nowWall(): String = Instant.now().toString()
|
||||
}
|
||||
|
||||
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
|
||||
@Serializable
|
||||
data class EchoMetrics(
|
||||
val sent: Int,
|
||||
val received: Int,
|
||||
@SerialName("loss_pct") val lossPct: Double,
|
||||
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
|
||||
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
|
||||
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
|
||||
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
|
||||
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Runs the full server-facing engine against a live server and validates the produced
|
||||
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
|
||||
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
|
||||
*/
|
||||
class LiveMeasurementTest {
|
||||
|
||||
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 producesValidDocumentFromLiveServer() {
|
||||
if (url == null || pin == null || cred == null || udp == null) {
|
||||
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
|
||||
return
|
||||
}
|
||||
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||
val engine = ServerMeasurement(
|
||||
ids = SystemIdSource(),
|
||||
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
|
||||
device = DeviceInfo("test", "jvm", 0, "n/a"),
|
||||
)
|
||||
val doc = engine.run(
|
||||
ServerMeasurement.Config(
|
||||
controlUrl = url, pins = setOf(pin), credential = cred,
|
||||
target = target, udpHost = host, udpPort = port, echoCount = 20,
|
||||
)
|
||||
)
|
||||
|
||||
// The document must round-trip and carry the expected structure.
|
||||
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
|
||||
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
|
||||
|
||||
assertEquals(1, doc.serverSessions.size)
|
||||
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
|
||||
val test = doc.tests.single()
|
||||
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
|
||||
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
|
||||
"expected replies from live server, got ${test.status}")
|
||||
|
||||
val metrics = Json.parseToJsonElement(test.metrics.toString())
|
||||
println("metrics: $metrics")
|
||||
assertTrue(metrics.toString().contains("rtt_ms_avg"))
|
||||
|
||||
assertTrue(doc.summary != null)
|
||||
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
|
||||
println("summary: ${doc.summary}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package measurement models one measurement run (measurement-schema.md) — the archived,
|
||||
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
|
||||
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
|
||||
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
|
||||
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class MeasurementDocument(
|
||||
val schema: String = "echolot/measurement",
|
||||
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
|
||||
val run: Run,
|
||||
val networks: List<Network> = emptyList(),
|
||||
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
|
||||
val tests: List<Test> = emptyList(),
|
||||
val findings: List<Finding> = emptyList(),
|
||||
val summary: Summary? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Run(
|
||||
val id: String, // UUIDv7
|
||||
val trigger: Trigger,
|
||||
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||
@SerialName("ended_at") val endedAt: String? = null,
|
||||
val clock: Clock,
|
||||
val app: AppInfo,
|
||||
val device: DeviceInfo,
|
||||
val tiers: Tiers,
|
||||
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Trigger {
|
||||
@SerialName("manual") MANUAL,
|
||||
@SerialName("scheduled") SCHEDULED,
|
||||
@SerialName("monitor") MONITOR,
|
||||
@SerialName("peer") PEER,
|
||||
}
|
||||
|
||||
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
|
||||
* all math uses *_mono_ns relative to that monotonic origin. */
|
||||
@Serializable
|
||||
data class Clock(
|
||||
@SerialName("mono_origin_wall") val monoOriginWall: String,
|
||||
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
|
||||
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppInfo(
|
||||
val version: String,
|
||||
val build: Int,
|
||||
val git: String? = null,
|
||||
val flavor: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceInfo(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
@SerialName("android_sdk") val androidSdk: Int,
|
||||
@SerialName("android_release") val androidRelease: String,
|
||||
@SerialName("security_patch") val securityPatch: String? = null,
|
||||
)
|
||||
|
||||
/** What each tier was *available*; each test records what it *used*. */
|
||||
@Serializable
|
||||
data class Tiers(
|
||||
val app: Boolean = true,
|
||||
val shizuku: Boolean = false,
|
||||
val root: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerSession(
|
||||
val id: String,
|
||||
@SerialName("profile_id") val profileId: String? = null,
|
||||
@SerialName("profile_name") val profileName: String? = null,
|
||||
@SerialName("control_url") val controlUrl: String,
|
||||
@SerialName("server_version") val serverVersion: String? = null,
|
||||
val capabilities: List<String> = emptyList(),
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val target: SessionTarget,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionTarget(
|
||||
val ip4: String? = null,
|
||||
val ip6: String? = null,
|
||||
@SerialName("udp_port") val udpPort: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
|
||||
/**
|
||||
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
|
||||
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
|
||||
* means the columnar/traceroute/resolver contracts live in one place.
|
||||
*/
|
||||
|
||||
@PublishedApi
|
||||
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
|
||||
|
||||
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
|
||||
inline fun <reified T> T.toEvidence(): JsonObject =
|
||||
evidenceJson.encodeToJsonElement(this) as JsonObject
|
||||
|
||||
/**
|
||||
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
|
||||
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
|
||||
* columns use the server session epoch; only differences within one clock are meaningful unless a
|
||||
* time.server_offset test maps them.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrainEvidence(
|
||||
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
|
||||
val seq: List<Int>,
|
||||
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
|
||||
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
|
||||
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
|
||||
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
|
||||
@SerialName("size_bytes") val sizeBytes: List<Int>,
|
||||
@SerialName("dscp_sent") val dscpSent: Int? = null,
|
||||
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
|
||||
@SerialName("ecn_sent") val ecnSent: Int? = null,
|
||||
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
|
||||
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
|
||||
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
|
||||
@Serializable
|
||||
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
|
||||
|
||||
@Serializable
|
||||
data class Flow(
|
||||
@SerialName("src_port") val srcPort: Int,
|
||||
@SerialName("dst_port") val dstPort: Int,
|
||||
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Hop(val ttl: Int, val probes: List<HopProbe>)
|
||||
|
||||
@Serializable
|
||||
data class HopProbe(
|
||||
@SerialName("reply_from") val replyFrom: String? = null,
|
||||
@SerialName("rtt_ns") val rttNs: Long? = null,
|
||||
val icmp: String? = null,
|
||||
@SerialName("reply_ttl") val replyTtl: Int? = null,
|
||||
)
|
||||
|
||||
/** Resolver under test (§6.4); every dns.* test carries this in params. */
|
||||
@Serializable
|
||||
data class ResolverSpec(
|
||||
val source: ResolverSource,
|
||||
val address: String? = null,
|
||||
val port: Int = 53,
|
||||
val transport: String, // do53-udp | do53-tcp | dot | doh
|
||||
@SerialName("doh_url") val dohUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ResolverSource {
|
||||
@SerialName("system") SYSTEM,
|
||||
@SerialName("manual") MANUAL,
|
||||
@SerialName("server-recursive") SERVER_RECURSIVE,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
|
||||
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
|
||||
@Serializable
|
||||
data class Finding(
|
||||
val id: String, // UUIDv7
|
||||
val code: String, // stable registry (findings-registry.md), lint-rule style
|
||||
val category: Category,
|
||||
val severity: Severity,
|
||||
val confidence: Confidence,
|
||||
@SerialName("network_ref") val networkRef: String? = null,
|
||||
val title: String,
|
||||
val description: String,
|
||||
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
|
||||
val recommendation: String? = null,
|
||||
) {
|
||||
init {
|
||||
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EvidenceRef(val test: String, val pointer: String? = null)
|
||||
|
||||
/** Fixed §7.2 categories; each maps to one traffic light. */
|
||||
@Serializable
|
||||
enum class Category {
|
||||
@SerialName("connectivity") CONNECTIVITY,
|
||||
@SerialName("dns") DNS,
|
||||
@SerialName("nat") NAT,
|
||||
@SerialName("mtu") MTU,
|
||||
@SerialName("ipv6") IPV6,
|
||||
@SerialName("security") SECURITY,
|
||||
@SerialName("performance") PERFORMANCE,
|
||||
@SerialName("local") LOCAL,
|
||||
@SerialName("wifi") WIFI,
|
||||
}
|
||||
|
||||
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
|
||||
@Serializable
|
||||
enum class Severity(val rank: Int) {
|
||||
@SerialName("critical") CRITICAL(4),
|
||||
@SerialName("high") HIGH(3),
|
||||
@SerialName("medium") MEDIUM(2),
|
||||
@SerialName("low") LOW(1),
|
||||
@SerialName("info") INFO(0);
|
||||
|
||||
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
|
||||
fun toLight(): Verdict = when (this) {
|
||||
CRITICAL, HIGH -> Verdict.RED
|
||||
MEDIUM, LOW -> Verdict.YELLOW
|
||||
INFO -> Verdict.GREEN
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Confidence {
|
||||
@SerialName("high") HIGH,
|
||||
@SerialName("medium") MEDIUM,
|
||||
@SerialName("low") LOW,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
|
||||
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
|
||||
@Serializable
|
||||
data class Network(
|
||||
val id: String,
|
||||
val transport: Transport,
|
||||
@SerialName("interface") val iface: String? = null,
|
||||
val link: Link,
|
||||
val wifi: Wifi? = null,
|
||||
val cellular: Cellular? = null,
|
||||
val changes: List<NetworkChange> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Transport {
|
||||
@SerialName("wifi") WIFI,
|
||||
@SerialName("cellular") CELLULAR,
|
||||
@SerialName("ethernet") ETHERNET,
|
||||
@SerialName("vpn") VPN,
|
||||
@SerialName("other") OTHER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Link(
|
||||
val mtu: Int? = null,
|
||||
val addresses: List<Address> = emptyList(),
|
||||
val routes: List<Route> = emptyList(),
|
||||
val dns: DnsConfig? = null,
|
||||
val dhcp: Dhcp? = null,
|
||||
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Address(
|
||||
val addr: String, // ip4 | ip6 (logical type, §8)
|
||||
@SerialName("prefix_len") val prefixLen: Int,
|
||||
val scope: String? = null,
|
||||
val flags: List<String> = emptyList(),
|
||||
@SerialName("valid_lft_s") val validLftS: Long? = null,
|
||||
@SerialName("pref_lft_s") val prefLftS: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Route(
|
||||
val dst: String,
|
||||
val gateway: String? = null,
|
||||
val iface: String? = null,
|
||||
val proto: RouteProto? = null, // shizuku tier; null = not observed
|
||||
@SerialName("expires_s") val expiresS: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class RouteProto {
|
||||
@SerialName("dhcp") DHCP,
|
||||
@SerialName("ra") RA,
|
||||
@SerialName("static") STATIC,
|
||||
@SerialName("unknown") UNKNOWN,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DnsConfig(
|
||||
val servers: List<String> = emptyList(),
|
||||
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
|
||||
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
|
||||
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
|
||||
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
|
||||
|
||||
@Serializable
|
||||
data class CaptivePortal(
|
||||
val detected: Boolean = false,
|
||||
@SerialName("api_url") val apiUrl: String? = null,
|
||||
@SerialName("venue_url") val venueUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Wifi(
|
||||
val ssid: String? = null, // ssid (logical type)
|
||||
val bssid: String? = null, // bssid (logical type)
|
||||
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
|
||||
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
|
||||
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
|
||||
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
|
||||
val standard: String? = null,
|
||||
val security: String? = null,
|
||||
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Cellular(
|
||||
val rat: String? = null,
|
||||
val operator: String? = null,
|
||||
val band: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkChange(
|
||||
@SerialName("at_mono_ns") val atMonoNs: Long,
|
||||
val kind: String, // lost | gained | link_changed
|
||||
val detail: JsonObject? = null,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class Verdict {
|
||||
@SerialName("green") GREEN,
|
||||
@SerialName("yellow") YELLOW,
|
||||
@SerialName("red") RED,
|
||||
@SerialName("inconclusive") INCONCLUSIVE,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Summary(
|
||||
val overall: Verdict,
|
||||
val categories: Map<String, CategorySummary>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CategorySummary(
|
||||
val verdict: Verdict,
|
||||
@SerialName("worst_finding") val worstFinding: String? = null,
|
||||
@SerialName("tests_run") val testsRun: Int,
|
||||
@SerialName("tests_failed") val testsFailed: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
|
||||
*
|
||||
* - A category's verdict = the light of its worst-severity finding
|
||||
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
||||
*
|
||||
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||
* findings or tests appear in the summary.
|
||||
*/
|
||||
object Verdicts {
|
||||
|
||||
private fun isInconclusiveTest(s: TestStatus) =
|
||||
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||
|
||||
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
||||
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||
val findingsByCat = findings.groupBy { it.category }
|
||||
val categories = (testsByCat.keys + findingsByCat.keys)
|
||||
|
||||
val perCat = LinkedHashMap<String, CategorySummary>()
|
||||
for (cat in Category.entries) {
|
||||
if (cat !in categories) continue
|
||||
val catTests = testsByCat[cat].orEmpty()
|
||||
val catFindings = findingsByCat[cat].orEmpty()
|
||||
|
||||
val failed = catTests.count { isInconclusiveTest(it.status) }
|
||||
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
|
||||
|
||||
val worst = catFindings.maxByOrNull { it.severity.rank }
|
||||
val verdict = when {
|
||||
inconclusive -> Verdict.INCONCLUSIVE
|
||||
worst == null -> Verdict.GREEN
|
||||
else -> worst.severity.toLight()
|
||||
}
|
||||
perCat[serialName(cat)] = CategorySummary(
|
||||
verdict = verdict,
|
||||
worstFinding = worst?.id,
|
||||
testsRun = catTests.size,
|
||||
testsFailed = failed,
|
||||
)
|
||||
}
|
||||
|
||||
val overall = deriveOverall(perCat.values)
|
||||
return Summary(overall = overall, categories = perCat)
|
||||
}
|
||||
|
||||
/** Overall = worst light; inconclusive only if every category is inconclusive. */
|
||||
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
|
||||
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
|
||||
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
|
||||
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
|
||||
// Non-inconclusive categories decide the overall light.
|
||||
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
|
||||
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
|
||||
}
|
||||
|
||||
private fun serialName(cat: Category): String = cat.name.lowercase()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
|
||||
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
|
||||
* per-test-type JSON, so they are carried as JsonObject. */
|
||||
@Serializable
|
||||
data class Test(
|
||||
val id: String, // UUIDv7
|
||||
val type: String, // TestType registry (§6.1)
|
||||
@SerialName("network_ref") val networkRef: String? = null,
|
||||
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
|
||||
val tier: Tier,
|
||||
@SerialName("started_mono_ns") val startedMonoNs: Long,
|
||||
@SerialName("ended_mono_ns") val endedMonoNs: Long,
|
||||
val status: TestStatus,
|
||||
val error: TestError? = null,
|
||||
val params: JsonObject? = null,
|
||||
val evidence: JsonObject? = null,
|
||||
val metrics: JsonObject? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Tier {
|
||||
@SerialName("app") APP,
|
||||
@SerialName("shizuku") SHIZUKU,
|
||||
@SerialName("root") ROOT,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class TestStatus {
|
||||
@SerialName("ok") OK,
|
||||
@SerialName("failed") FAILED,
|
||||
@SerialName("unsupported") UNSUPPORTED,
|
||||
@SerialName("skipped") SKIPPED,
|
||||
@SerialName("partial") PARTIAL,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TestError(val code: String, val detail: String? = null)
|
||||
|
||||
/**
|
||||
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
|
||||
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
|
||||
* the fixed §7.2 categories for verdict rollup.
|
||||
*/
|
||||
object TestType {
|
||||
// link
|
||||
const val LINK_SNAPSHOT = "link.snapshot"
|
||||
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||
const val LINK_IP_MONITOR = "link.ip_monitor"
|
||||
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||
// icmp
|
||||
const val ICMP_PING4 = "icmp.ping4"
|
||||
const val ICMP_PING6 = "icmp.ping6"
|
||||
// trace
|
||||
const val TRACEROUTE_UDP4 = "traceroute.udp4"
|
||||
const val TRACEROUTE_UDP6 = "traceroute.udp6"
|
||||
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
|
||||
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
|
||||
// train
|
||||
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
|
||||
// mtu
|
||||
const val MTU_PMTUD_UP = "mtu.pmtud_up"
|
||||
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
|
||||
const val MTU_BLACKHOLE = "mtu.blackhole"
|
||||
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
|
||||
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
|
||||
// nat
|
||||
const val NAT_STUN_5780 = "nat.stun_5780"
|
||||
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
|
||||
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
|
||||
const val NAT_HAIRPIN = "nat.hairpin"
|
||||
const val NAT_CONNECT_BACK = "nat.connect_back"
|
||||
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
|
||||
// dns
|
||||
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||
const val DNS_CANARY = "dns.canary"
|
||||
const val DNS_INTERCEPTION = "dns.interception"
|
||||
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||
const val DNS_DNSSEC = "dns.dnssec"
|
||||
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
|
||||
const val DNS_REBIND_FILTER = "dns.rebind_filter"
|
||||
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
|
||||
const val DNS_DNS64 = "dns.dns64"
|
||||
const val DNS_COMPARE = "dns.compare"
|
||||
// sec
|
||||
const val SEC_TLS_REFERENCE = "sec.tls_reference"
|
||||
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
|
||||
const val SEC_HTTP_ECHO = "sec.http_echo"
|
||||
const val SEC_SNI_FILTER = "sec.sni_filter"
|
||||
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
|
||||
const val SEC_ARP_WATCH = "sec.arp_watch"
|
||||
// port
|
||||
const val PORT_REACH_SWEEP = "port.reach_sweep"
|
||||
const val PORT_UDP_USABILITY = "port.udp_usability"
|
||||
// perf
|
||||
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
|
||||
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
|
||||
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
|
||||
const val PERF_RRC_LATENCY = "perf.rrc_latency"
|
||||
// v6
|
||||
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
|
||||
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
|
||||
const val V6_BROKENNESS = "v6.brokenness"
|
||||
const val V6_NAT64_CLAT = "v6.nat64_clat"
|
||||
// wifi
|
||||
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
|
||||
const val WIFI_ROAM_LOG = "wifi.roam_log"
|
||||
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
|
||||
// local
|
||||
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||
const val LOCAL_NTP = "local.ntp"
|
||||
// peer
|
||||
const val PEER_REACHABILITY = "peer.reachability"
|
||||
const val PEER_ISOLATION = "peer.isolation"
|
||||
const val PEER_MULTICAST = "peer.multicast"
|
||||
const val PEER_LAN_TRAIN = "peer.lan_train"
|
||||
const val PEER_LEASE_DIFF = "peer.lease_diff"
|
||||
// time
|
||||
const val TIME_SERVER_OFFSET = "time.server_offset"
|
||||
|
||||
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
|
||||
fun category(type: String): Category = when (type.substringBefore('.')) {
|
||||
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
|
||||
"dns" -> Category.DNS
|
||||
"nat" -> Category.NAT
|
||||
"mtu" -> Category.MTU
|
||||
"v6" -> Category.IPV6
|
||||
"sec" -> Category.SECURITY
|
||||
"perf" -> Category.PERFORMANCE
|
||||
"local", "peer" -> Category.LOCAL
|
||||
"wifi" -> Category.WIFI
|
||||
else -> Category.CONNECTIVITY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test as JTest
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SerializationTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@JTest
|
||||
fun documentRoundTrips() {
|
||||
val doc = MeasurementDocument(
|
||||
run = Run(
|
||||
id = "0198c5f2-0000-7000-8000-000000000000",
|
||||
trigger = Trigger.MANUAL,
|
||||
startedAt = "2026-07-31T14:03:21.114Z",
|
||||
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
|
||||
app = AppInfo(version = "0.1.0", build = 1),
|
||||
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
|
||||
tiers = Tiers(app = true, shizuku = true),
|
||||
),
|
||||
networks = listOf(
|
||||
Network(
|
||||
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
|
||||
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
|
||||
wifi = Wifi(ssid = "example", rssiDbm = -54),
|
||||
),
|
||||
),
|
||||
tests = listOf(
|
||||
Test(
|
||||
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
|
||||
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
|
||||
evidence = TrainEvidence(
|
||||
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
|
||||
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
|
||||
).toEvidence(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
|
||||
assertEquals(doc.run.id, decoded.run.id)
|
||||
assertEquals(Transport.WIFI, decoded.networks[0].transport)
|
||||
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
|
||||
// snake_case field names on the wire
|
||||
assertTrue(encoded.contains("\"schema_version\""))
|
||||
assertTrue(encoded.contains("\"mono_origin_wall\""))
|
||||
assertTrue(encoded.contains("\"t_tx_ns\""))
|
||||
// null preserved at train index 1
|
||||
assertTrue(encoded.contains("[16500000,null]"))
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun findingRequiresEvidence() {
|
||||
try {
|
||||
Finding(
|
||||
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
|
||||
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
|
||||
)
|
||||
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// expected — a finding with no evidence is invalid (§7.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlin.test.Test as JTest
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class VerdictsTest {
|
||||
|
||||
private fun test(type: String, status: TestStatus, id: String = type): Test =
|
||||
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
|
||||
|
||||
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
|
||||
Finding(
|
||||
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
|
||||
)
|
||||
|
||||
@JTest
|
||||
fun categoryLightFromWorstSeverity() {
|
||||
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
|
||||
val findings = listOf(
|
||||
finding(Category.DNS, Severity.LOW),
|
||||
finding(Category.DNS, Severity.HIGH), // worst → red
|
||||
finding(Category.DNS, Severity.INFO),
|
||||
)
|
||||
val s = Verdicts.derive(tests, findings)
|
||||
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
|
||||
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
|
||||
assertEquals(Verdict.RED, s.overall)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun noFindingsIsGreen() {
|
||||
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
|
||||
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||
assertEquals(Verdict.GREEN, s.overall)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun mediumAndLowAreYellow() {
|
||||
val s = Verdicts.derive(
|
||||
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
|
||||
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
|
||||
)
|
||||
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun majorityFailedIsInconclusive() {
|
||||
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
|
||||
val tests = listOf(
|
||||
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
|
||||
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
|
||||
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
|
||||
)
|
||||
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
|
||||
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||
assertEquals(2, s.categories["dns"]!!.testsFailed)
|
||||
assertEquals(3, s.categories["dns"]!!.testsRun)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun exactlyHalfFailedIsNotInconclusive() {
|
||||
// 1 of 2 failed → not > 50% → the finding decides.
|
||||
val tests = listOf(
|
||||
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
|
||||
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
|
||||
)
|
||||
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
|
||||
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun overallIsWorstCategory() {
|
||||
val tests = listOf(
|
||||
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
|
||||
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
|
||||
)
|
||||
val findings = listOf(
|
||||
finding(Category.DNS, Severity.MEDIUM), // yellow
|
||||
finding(Category.MTU, Severity.CRITICAL), // red
|
||||
)
|
||||
val s = Verdicts.derive(tests, findings)
|
||||
assertEquals(Verdict.RED, s.overall)
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun overallInconclusiveOnlyWhenAllAre() {
|
||||
val tests = listOf(
|
||||
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
|
||||
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
|
||||
)
|
||||
val s = Verdicts.derive(tests, emptyList())
|
||||
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
|
||||
}
|
||||
|
||||
@JTest
|
||||
fun categoryMappingCoversFamilies() {
|
||||
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
|
||||
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
|
||||
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
|
||||
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
|
||||
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.measurement.Test
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestType
|
||||
import app.echo_lot.measurement.Tier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* dns.canary / dns.answer_integrity — resolves the server's canary zone through the network's own
|
||||
* resolver and compares against the spec-frozen ground truth (probe-protocol.md §6.1).
|
||||
*
|
||||
* Two things are checked, and they detect different failures:
|
||||
* - **Reference records** (`ttl-5`, `many-rr`, …) have FIXED RDATA fixed by the spec, so a
|
||||
* mismatch means the answer was rewritten in flight (interception/filtering).
|
||||
* - A **per-run nonce name** `<nonce>.<session>.<zone>` can never have been cached, so it proves
|
||||
* the query reached the authoritative server, and the answer is derived from the nonce itself.
|
||||
*
|
||||
* Resolution goes through the platform resolver (InetAddress), i.e. exactly the path apps use —
|
||||
* so interception by the network's DNS is what we measure. The server side records who actually
|
||||
* asked (its observation API), letting the app pair "what I got" with "who asked".
|
||||
*/
|
||||
class DnsCanaryProbe(
|
||||
private val canaryZone: String,
|
||||
private val sessionPrefix: String,
|
||||
private val nonce: String = java.util.UUID.randomUUID().toString().take(8),
|
||||
) : Probe {
|
||||
override val type = TestType.DNS_CANARY
|
||||
override val tier = Tier.APP
|
||||
|
||||
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
|
||||
private val references = listOf(
|
||||
Reference("ttl-5", "192.0.2.5"),
|
||||
Reference("ttl-60", "192.0.2.60"),
|
||||
Reference("ttl-3600", "192.0.2.36"),
|
||||
Reference("ttl-86400", "192.0.2.86"),
|
||||
)
|
||||
|
||||
private data class Reference(val label: String, val expectedA: String)
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
if (canaryZone.isBlank()) {
|
||||
return@withContext b.build(
|
||||
TestStatus.SKIPPED,
|
||||
evidence = buildJsonObject { put("reason", "no canary zone configured (needs a server profile)") },
|
||||
)
|
||||
}
|
||||
|
||||
var matched = 0
|
||||
var mismatched = 0
|
||||
var failed = 0
|
||||
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
put("zone", canaryZone)
|
||||
putJsonObject("reference_records") {
|
||||
for (r in references) {
|
||||
val fqdn = "${r.label}.$canaryZone"
|
||||
val got = resolveA(fqdn)
|
||||
putJsonObject(r.label) {
|
||||
put("fqdn", fqdn); put("expected", r.expectedA); put("got", got ?: "")
|
||||
val verdict = when {
|
||||
got == null -> { failed++; "resolve_failed" }
|
||||
got == r.expectedA -> { matched++; "match" }
|
||||
else -> { mismatched++; "MISMATCH (answer rewritten in flight)" }
|
||||
}
|
||||
put("verdict", verdict)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cache-miss proof: a nonce name that cannot have been pre-cached.
|
||||
val nonceFqdn = "$nonce.$sessionPrefix.$canaryZone"
|
||||
val nonceGot = resolveA(nonceFqdn)
|
||||
putJsonObject("nonce_query") {
|
||||
put("fqdn", nonceFqdn)
|
||||
put("got", nonceGot ?: "")
|
||||
// The server answers nonce names from 192.0.2.0/24 (deterministic per nonce).
|
||||
val reached = nonceGot?.startsWith("192.0.2.") == true
|
||||
put("reached_authoritative", reached)
|
||||
put("note", "a non-192.0.2.x answer means something other than the canary server replied")
|
||||
}
|
||||
}
|
||||
|
||||
val metrics = buildJsonObject {
|
||||
put("references_matched", matched); put("references_mismatched", mismatched)
|
||||
put("references_failed", failed)
|
||||
}
|
||||
val status = when {
|
||||
mismatched > 0 -> TestStatus.PARTIAL // answers altered — a finding
|
||||
matched == 0 -> TestStatus.FAILED // nothing resolved
|
||||
failed > 0 -> TestStatus.PARTIAL
|
||||
else -> TestStatus.OK
|
||||
}
|
||||
b.build(status, evidence = evidence, metrics = metrics)
|
||||
}
|
||||
|
||||
/** First IPv4 answer via the platform resolver (the path a normal app takes), or null. */
|
||||
private fun resolveA(fqdn: String): String? = runCatching {
|
||||
InetAddress.getAllByName(fqdn).firstOrNull { it is java.net.Inet4Address }?.hostAddress
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -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,385 @@
|
||||
{
|
||||
"schema": "echolot/measurement",
|
||||
"schema_version": "1.0.0",
|
||||
"run": {
|
||||
"id": "4d92f749-5222-440f-b6a4-def717f0da3b",
|
||||
"trigger": "manual",
|
||||
"started_at": "2026-08-01T07:00:58.005733Z",
|
||||
"ended_at": "2026-08-01T07:01:08.588026Z",
|
||||
"clock": {
|
||||
"mono_origin_wall": "2026-08-01T07:00:58.005733Z",
|
||||
"ntp_offset_ms": null,
|
||||
"ntp_offset_source": null
|
||||
},
|
||||
"app": {
|
||||
"version": "0.1.0",
|
||||
"build": 1,
|
||||
"git": null,
|
||||
"flavor": "app"
|
||||
},
|
||||
"device": {
|
||||
"manufacturer": "OnePlus",
|
||||
"model": "CPH2747",
|
||||
"android_sdk": 36,
|
||||
"android_release": "16",
|
||||
"security_patch": null
|
||||
},
|
||||
"tiers": {
|
||||
"app": true,
|
||||
"shizuku": false,
|
||||
"root": false
|
||||
},
|
||||
"profiles_used": [],
|
||||
"notes": null
|
||||
},
|
||||
"networks": [
|
||||
{
|
||||
"id": "net-0",
|
||||
"transport": "cellular",
|
||||
"interface": "rmnet_data4",
|
||||
"link": {
|
||||
"mtu": 1500,
|
||||
"addresses": [
|
||||
{
|
||||
"addr": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c",
|
||||
"prefix_len": 64,
|
||||
"scope": null,
|
||||
"flags": [],
|
||||
"valid_lft_s": null,
|
||||
"pref_lft_s": null
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"dst": "::/0",
|
||||
"gateway": "fe80::246f:12be:21ef:1b54",
|
||||
"iface": "rmnet_data4",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
},
|
||||
{
|
||||
"dst": "2001:4bb8:417:bd78::/64",
|
||||
"gateway": "::",
|
||||
"iface": "rmnet_data4",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
}
|
||||
],
|
||||
"dns": {
|
||||
"servers": [
|
||||
"fda1:3fb1:0:8:0:10:0:101",
|
||||
"fda1:3fb1:0:8:0:10:0:100"
|
||||
],
|
||||
"private_dns_mode": "off",
|
||||
"private_dns_hostname": null,
|
||||
"search_domains": [],
|
||||
"nat64_prefix": null
|
||||
},
|
||||
"dhcp": null,
|
||||
"captive_portal": null
|
||||
},
|
||||
"wifi": null,
|
||||
"cellular": null,
|
||||
"changes": []
|
||||
},
|
||||
{
|
||||
"id": "net-1",
|
||||
"transport": "wifi",
|
||||
"interface": "wlan0",
|
||||
"link": {
|
||||
"mtu": null,
|
||||
"addresses": [
|
||||
{
|
||||
"addr": "fe80::7a:75ff:fee9:ae9e",
|
||||
"prefix_len": 64,
|
||||
"scope": null,
|
||||
"flags": [],
|
||||
"valid_lft_s": null,
|
||||
"pref_lft_s": null
|
||||
},
|
||||
{
|
||||
"addr": "10.13.102.124",
|
||||
"prefix_len": 24,
|
||||
"scope": null,
|
||||
"flags": [],
|
||||
"valid_lft_s": null,
|
||||
"pref_lft_s": null
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"dst": "fe80::/64",
|
||||
"gateway": "::",
|
||||
"iface": "wlan0",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
},
|
||||
{
|
||||
"dst": "::/0",
|
||||
"gateway": "fe80::7a9a:18ff:fe54:b8f9",
|
||||
"iface": "wlan0",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
},
|
||||
{
|
||||
"dst": "10.13.102.0/24",
|
||||
"gateway": "0.0.0.0",
|
||||
"iface": "wlan0",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
},
|
||||
{
|
||||
"dst": "0.0.0.0/0",
|
||||
"gateway": "10.13.102.1",
|
||||
"iface": "wlan0",
|
||||
"proto": null,
|
||||
"expires_s": null
|
||||
}
|
||||
],
|
||||
"dns": {
|
||||
"servers": [
|
||||
"10.13.102.1"
|
||||
],
|
||||
"private_dns_mode": "off",
|
||||
"private_dns_hostname": null,
|
||||
"search_domains": [
|
||||
"hudelist.local"
|
||||
],
|
||||
"nat64_prefix": null
|
||||
},
|
||||
"dhcp": null,
|
||||
"captive_portal": null
|
||||
},
|
||||
"wifi": null,
|
||||
"cellular": null,
|
||||
"changes": []
|
||||
}
|
||||
],
|
||||
"server_sessions": [],
|
||||
"tests": [
|
||||
{
|
||||
"id": "83174b93-b8f3-4c73-86d1-d4d67866ec91",
|
||||
"type": "link.snapshot",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "app",
|
||||
"started_mono_ns": 1990364,
|
||||
"ended_mono_ns": 2119375,
|
||||
"status": "ok",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"network_count": 2,
|
||||
"networks": [
|
||||
{
|
||||
"id": "net-0",
|
||||
"transport": "cellular",
|
||||
"interface": "rmnet_data4",
|
||||
"mtu": 1500,
|
||||
"addresses": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c/64",
|
||||
"dns": "fda1:3fb1:0:8:0:10:0:101, fda1:3fb1:0:8:0:10:0:100",
|
||||
"nat64": "none"
|
||||
},
|
||||
{
|
||||
"id": "net-1",
|
||||
"transport": "wifi",
|
||||
"interface": "wlan0",
|
||||
"mtu": 0,
|
||||
"addresses": "fe80::7a:75ff:fee9:ae9e/64, 10.13.102.124/24",
|
||||
"dns": "10.13.102.1",
|
||||
"nat64": "none"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metrics": null
|
||||
},
|
||||
{
|
||||
"id": "4fcd2393-3464-4d00-a15e-8006e178fefd",
|
||||
"type": "icmp.ping4",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "app",
|
||||
"started_mono_ns": 2260885,
|
||||
"ended_mono_ns": 86194427,
|
||||
"status": "ok",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"target": "1.1.1.1",
|
||||
"default": "reply type=0 rtt_ms=43.1 bytes=15",
|
||||
"cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)",
|
||||
"wifi:net-1": "reply type=0 rtt_ms=39.3 bytes=15"
|
||||
},
|
||||
"metrics": {
|
||||
"networks_ok": 2,
|
||||
"rtt_ms_min": 39.3,
|
||||
"rtt_ms_avg": 41.2,
|
||||
"rtt_ms_max": 43.1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5a51060f-6d65-432f-ae2f-09a896f989d2",
|
||||
"type": "icmp.ping6",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "app",
|
||||
"started_mono_ns": 86622343,
|
||||
"ended_mono_ns": 6407352028,
|
||||
"status": "failed",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"target": "2606:4700:4700::1111",
|
||||
"default": "error: recvfrom failed: EAGAIN (Try again)",
|
||||
"cellular:net-0": "error: Binding socket to network 137 failed: EPERM (Operation not permitted)",
|
||||
"wifi:net-1": "error: recvfrom failed: EAGAIN (Try again)"
|
||||
},
|
||||
"metrics": {
|
||||
"networks_ok": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f0e48635-5688-4cdb-9a7c-7e26047427e6",
|
||||
"type": "net.captive_portal",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "app",
|
||||
"started_mono_ns": 6407902080,
|
||||
"ended_mono_ns": 10451588485,
|
||||
"status": "ok",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"https_url": "https://www.google.com/generate_204",
|
||||
"http_url": "http://connectivitycheck.gstatic.com/generate_204",
|
||||
"default": {
|
||||
"https_code": 204,
|
||||
"http_code": 204,
|
||||
"verdict": "validated"
|
||||
},
|
||||
"cellular:net-0": {
|
||||
"https_code": -1,
|
||||
"http_code": -1,
|
||||
"verdict": "no_internet"
|
||||
},
|
||||
"wifi:net-1": {
|
||||
"https_code": 204,
|
||||
"http_code": 204,
|
||||
"verdict": "validated"
|
||||
}
|
||||
},
|
||||
"metrics": null
|
||||
},
|
||||
{
|
||||
"id": "7811704e-935b-4dd6-ad9c-d97f4bb6db5d",
|
||||
"type": "dns.canary",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "app",
|
||||
"started_mono_ns": 10452107548,
|
||||
"ended_mono_ns": 10579626037,
|
||||
"status": "ok",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"zone": "c.echo-lot.app",
|
||||
"reference_records": {
|
||||
"ttl-5": {
|
||||
"fqdn": "ttl-5.c.echo-lot.app",
|
||||
"expected": "192.0.2.5",
|
||||
"got": "192.0.2.5",
|
||||
"verdict": "match"
|
||||
},
|
||||
"ttl-60": {
|
||||
"fqdn": "ttl-60.c.echo-lot.app",
|
||||
"expected": "192.0.2.60",
|
||||
"got": "192.0.2.60",
|
||||
"verdict": "match"
|
||||
},
|
||||
"ttl-3600": {
|
||||
"fqdn": "ttl-3600.c.echo-lot.app",
|
||||
"expected": "192.0.2.36",
|
||||
"got": "192.0.2.36",
|
||||
"verdict": "match"
|
||||
},
|
||||
"ttl-86400": {
|
||||
"fqdn": "ttl-86400.c.echo-lot.app",
|
||||
"expected": "192.0.2.86",
|
||||
"got": "192.0.2.86",
|
||||
"verdict": "match"
|
||||
}
|
||||
},
|
||||
"nonce_query": {
|
||||
"fqdn": "1006ad16.adhoc.c.echo-lot.app",
|
||||
"got": "192.0.2.21",
|
||||
"reached_authoritative": true,
|
||||
"note": "a non-192.0.2.x answer means something other than the canary server replied"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"references_matched": 4,
|
||||
"references_mismatched": 0,
|
||||
"references_failed": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7612b4c1-615a-4232-9969-7506c2ed77fc",
|
||||
"type": "link.ip_monitor",
|
||||
"network_ref": null,
|
||||
"session_ref": null,
|
||||
"tier": "shizuku",
|
||||
"started_mono_ns": 10580257287,
|
||||
"ended_mono_ns": 10580338173,
|
||||
"status": "unsupported",
|
||||
"error": null,
|
||||
"params": null,
|
||||
"evidence": {
|
||||
"binder_alive": false,
|
||||
"detail": "Shizuku not running"
|
||||
},
|
||||
"metrics": null
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"id": "5180f756-1bc9-4979-a6f2-4e354a9cba3f",
|
||||
"code": "ipv6.no_icmp_path",
|
||||
"category": "ipv6",
|
||||
"severity": "low",
|
||||
"confidence": "medium",
|
||||
"network_ref": null,
|
||||
"title": "No IPv6 ICMP path on any active network",
|
||||
"description": "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).",
|
||||
"evidence_refs": [
|
||||
{
|
||||
"test": "5a51060f-6d65-432f-ae2f-09a896f989d2",
|
||||
"pointer": null
|
||||
}
|
||||
],
|
||||
"recommendation": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"overall": "yellow",
|
||||
"categories": {
|
||||
"connectivity": {
|
||||
"verdict": "green",
|
||||
"worst_finding": null,
|
||||
"tests_run": 5,
|
||||
"tests_failed": 2
|
||||
},
|
||||
"dns": {
|
||||
"verdict": "green",
|
||||
"worst_finding": null,
|
||||
"tests_run": 1,
|
||||
"tests_failed": 0
|
||||
},
|
||||
"ipv6": {
|
||||
"verdict": "yellow",
|
||||
"worst_finding": "5180f756-1bc9-4979-a6f2-4e354a9cba3f",
|
||||
"tests_run": 0,
|
||||
"tests_failed": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user