diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index 3ae07c4..4a67204 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -90,6 +90,8 @@ class RunStore(context: Context, private val settings: Settings) { if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first." return try { val profile = client().profile(settings.serverCredential) + // Learned here so the next run's canary probe knows what to ask for. + profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it } val compat = Compat.check(profile, BuildConfig.APP_SEMVER) val head = "${profile.name} · server ${profile.serverVersion} · " + "protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}" @@ -150,6 +152,7 @@ class RunStore(context: Context, private val settings: Settings) { return try { val client = client() val profile = client.profile(settings.serverCredential) + profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it } // Compatibility before policy: an incompatible server may well advertise an upload // policy it would never actually apply to us. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index ced690a..09e8db2 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -314,8 +314,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { 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"), - StunProbe(serverHost = "fmr-1.echo-lot.app"), + // Both target whatever server this device is enrolled with, not the deployment the + // app happened to be developed against. With no server configured they get blank + // strings and report themselves skipped, which is the honest outcome — the + // alternative measures someone else's infrastructure and calls it your network. + DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"), + StunProbe(serverHost = settings.serverHost()), ) // Plan the run first: the Shizuku battery is counted alongside the app-tier probes so diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt index 1703ef6..0b7e658 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -104,6 +104,28 @@ class Settings(context: Context) { val serverConfigured: Boolean get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank() + /** + * The DNS zone this server is authoritative for, learned from its profile. + * + * Cached because the canary probe runs at device tier, before anything has talked to the + * server, and a probe that had to make a control-plane call first would fail on exactly the + * networks worth measuring. Empty means "not known yet", and the probe reports itself as + * skipped rather than inventing a zone. + */ + var canaryZone: String + get() = prefs.getString(CANARY_ZONE, "") ?: "" + set(v) = prefs.edit().putString(CANARY_ZONE, v.trim()).apply() + + /** + * Host part of the configured server URL, for probes that address it directly (STUN). + * + * Derived rather than stored: a second copy of the server's name is a second thing to keep in + * step, and it would go stale the moment someone re-enrolled against a different server. + */ + fun serverHost(): String = runCatching { + java.net.URI(serverUrl).host?.takeIf { it.isNotBlank() } + }.getOrNull() ?: "" + // ---- account --------------------------------------------------------------------- /** @@ -150,6 +172,7 @@ class Settings(context: Context) { const val SERVER_URL = "server_url" const val SERVER_PIN = "server_pin" const val SERVER_CRED = "server_credential" + const val CANARY_ZONE = "server_canary_zone" const val PENDING_VERIFIER = "pending_auth_verifier" const val PENDING_STATE = "pending_auth_state" const val ACCOUNT_NAME = "account_name" diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt index c482607..5be9c2b 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Document.kt @@ -35,9 +35,37 @@ data class Run( val device: DeviceInfo, val tiers: Tiers, @SerialName("profiles_used") val profilesUsed: List = emptyList(), + val constraints: Constraints = Constraints(), val notes: String? = null, ) +/** + * What limited this run — the counterpart to [Tiers], which records what was available. + * + * A constrained run is not a failed run, and it is not a normal one either. Without this, a run + * taken through a VPN looks exactly like a clean run of a healthy network: the same shape, the + * same green verdict, and no way for a reader — or a server aggregating thousands of these — to + * know that almost nothing was actually measured. + */ +@Serializable +data class Constraints( + /** A VPN held the default route while this ran. */ + @SerialName("vpn_active") val vpnActive: Boolean = false, + /** + * Per-network probing was refused by the OS. + * + * Android blocks `Network.bindSocket()` on the underlying networks whenever a VPN is up, to + * stop apps leaking around the tunnel. Every per-network test then measures nothing, so any + * conclusion drawn about the wifi or cellular link underneath is unfounded. + */ + @SerialName("per_network_blocked") val perNetworkBlocked: Boolean = false, + /** Networks that could not be measured, by id. */ + @SerialName("unmeasured_networks") val unmeasuredNetworks: List = emptyList(), +) { + /** True when this run's results mean something different from an unconstrained one. */ + val constrained: Boolean get() = vpnActive || perNetworkBlocked +} + @Serializable enum class Trigger { @SerialName("manual") MANUAL, diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt index f0c74e6..bcd2d18 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt @@ -214,6 +214,20 @@ object FindingRegistry { rulesOut = "A working IPv6 setup: SLAAC did not produce a usable address on this link.", ) + /** + * A VPN prevented the underlying networks from being measured. + * + * Reported rather than worked around: Android refuses `Network.bindSocket()` on the networks + * beneath a VPN precisely so apps cannot leak around the tunnel, and that is correct + * behaviour. What is not acceptable is a run that quietly measures nothing and calls the + * result healthy, so this says plainly which networks went unmeasured and why. + */ + val MEASUREMENT_VPN_CONSTRAINED = FindingSpec( + "measurement.vpn_constrained", Category.CONNECTIVITY, Severity.INFO, + "A VPN was active, so the networks underneath it could not be measured.", + rulesOut = "Nothing — this run says little about the underlying network either way.", + ) + val V6_NO_DEFAULT_ROUTE = FindingSpec( "v6.no_default_route", Category.IPV6, Severity.MEDIUM, "The device has a global IPv6 address but no IPv6 default route.", @@ -247,7 +261,7 @@ object FindingRegistry { NAT_UDP_REBINDING, NAT_SYMMETRIC, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, - V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED, + MEASUREMENT_VPN_CONSTRAINED, V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_NOT_OFFERED, ) private val byCode: Map = all.associateBy { it.code } diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt index 9cf23c3..cb100c7 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Summary.kt @@ -35,6 +35,9 @@ data class CategorySummary( * (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. + * - A run whose per-network probing was blocked is `inconclusive` outright, whatever the + * categories say. The lights describe what the tests found; when the OS refused to let the + * tests run, a green light would describe nothing at all. * * The mapping test-type → category comes from [TestType.category]. Only categories that have * findings or tests appear in the summary. @@ -44,7 +47,10 @@ object Verdicts { private fun isInconclusiveTest(s: TestStatus) = s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED - fun derive(tests: List, findings: List): Summary { + fun derive(tests: List, findings: List): Summary = + derive(tests, findings, Constraints()) + + fun derive(tests: List, findings: List, constraints: Constraints): Summary { val testsByCat = tests.groupBy { TestType.category(it.type) } val findingsByCat = findings.groupBy { it.category } val categories = (testsByCat.keys + findingsByCat.keys) @@ -72,7 +78,14 @@ object Verdicts { ) } - val overall = deriveOverall(perCat.values) + // A run that could not measure the networks it was asked about has not found them + // healthy; it has found out nothing. Reporting that as green is the single most + // misleading thing this function could do, so the constraint outranks the lights. + val overall = if (constraints.perNetworkBlocked) { + Verdict.INCONCLUSIVE + } else { + deriveOverall(perCat.values) + } return Summary(overall = overall, categories = perCat) } diff --git a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt index e9e0a0f..19b335d 100644 --- a/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt +++ b/echolot-app/core-probe/src/main/kotlin/app/echo_lot/probe/StunProbe.kt @@ -60,6 +60,15 @@ class StunProbe( override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) { val b = TestBuilder(type, tier, ids) + // Without a server there is nothing to ask. Skipped rather than failed: "the STUN test + // failed" reads as a finding about the network, when the truth is that this device is + // not enrolled anywhere and no packet was ever sent. + if (serverHost.isBlank()) { + return@withContext b.build( + TestStatus.SKIPPED, + evidence = buildJsonObject { put("reason", "no server configured to ask") }, + ) + } DatagramSocket().use { sock -> sock.soTimeout = 3000 val localPort = sock.localPort