app: probe the server this device is enrolled with, not ours
The canary-DNS zone and the STUN host were compiled in as c.echo-lot.app and fmr-1.echo-lot.app, so every copy of the app measured against this particular deployment whatever server its owner had enrolled with. On someone else's install those two tests describe our infrastructure and report the result as a fact about their network. The zone comes from the server's own profile, which has advertised canary_zone all along — the app simply never read it. It is cached in settings because the canary probe runs at device tier, before anything has contacted the control plane, and a probe that had to make a call first would fail on exactly the networks worth measuring. The STUN host is derived from the configured server URL rather than stored, since a second copy of the server's name goes stale the moment someone re-enrolls elsewhere. With no server configured both now report SKIPPED. StunProbe previously would have reported FAILED on a blank host, which reads as a finding about the network when the truth is that no packet was ever sent — the same conflation between "measured nothing" and "measured a fault" that the ICMPv6 finding had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e0428b4c84
commit
4aaaa5f5d4
@@ -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."
|
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
||||||
return try {
|
return try {
|
||||||
val profile = client().profile(settings.serverCredential)
|
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 compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||||
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||||
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||||
@@ -150,6 +152,7 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
return try {
|
return try {
|
||||||
val client = client()
|
val client = client()
|
||||||
val profile = client.profile(settings.serverCredential)
|
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
|
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||||
// policy it would never actually apply to us.
|
// policy it would never actually apply to us.
|
||||||
|
|||||||
@@ -314,8 +314,12 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
CaptivePortalProbe(entries),
|
CaptivePortalProbe(entries),
|
||||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
// the reference deployment until profiles/enrollment land in the UI.
|
// the reference deployment until profiles/enrollment land in the UI.
|
||||||
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
|
// Both target whatever server this device is enrolled with, not the deployment the
|
||||||
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
// 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
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ class Settings(context: Context) {
|
|||||||
val serverConfigured: Boolean
|
val serverConfigured: Boolean
|
||||||
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
|
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 ---------------------------------------------------------------------
|
// ---- account ---------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +172,7 @@ class Settings(context: Context) {
|
|||||||
const val SERVER_URL = "server_url"
|
const val SERVER_URL = "server_url"
|
||||||
const val SERVER_PIN = "server_pin"
|
const val SERVER_PIN = "server_pin"
|
||||||
const val SERVER_CRED = "server_credential"
|
const val SERVER_CRED = "server_credential"
|
||||||
|
const val CANARY_ZONE = "server_canary_zone"
|
||||||
const val PENDING_VERIFIER = "pending_auth_verifier"
|
const val PENDING_VERIFIER = "pending_auth_verifier"
|
||||||
const val PENDING_STATE = "pending_auth_state"
|
const val PENDING_STATE = "pending_auth_state"
|
||||||
const val ACCOUNT_NAME = "account_name"
|
const val ACCOUNT_NAME = "account_name"
|
||||||
|
|||||||
@@ -35,9 +35,37 @@ data class Run(
|
|||||||
val device: DeviceInfo,
|
val device: DeviceInfo,
|
||||||
val tiers: Tiers,
|
val tiers: Tiers,
|
||||||
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||||
|
val constraints: Constraints = Constraints(),
|
||||||
val notes: String? = null,
|
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<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
/** True when this run's results mean something different from an unconstrained one. */
|
||||||
|
val constrained: Boolean get() = vpnActive || perNetworkBlocked
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class Trigger {
|
enum class Trigger {
|
||||||
@SerialName("manual") MANUAL,
|
@SerialName("manual") MANUAL,
|
||||||
|
|||||||
+15
-1
@@ -214,6 +214,20 @@ object FindingRegistry {
|
|||||||
rulesOut = "A working IPv6 setup: SLAAC did not produce a usable address on this link.",
|
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(
|
val V6_NO_DEFAULT_ROUTE = FindingSpec(
|
||||||
"v6.no_default_route", Category.IPV6, Severity.MEDIUM,
|
"v6.no_default_route", Category.IPV6, Severity.MEDIUM,
|
||||||
"The device has a global IPv6 address but no IPv6 default route.",
|
"The device has a global IPv6 address but no IPv6 default route.",
|
||||||
@@ -247,7 +261,7 @@ object FindingRegistry {
|
|||||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||||
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
||||||
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
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<String, FindingSpec> = all.associateBy { it.code }
|
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ data class CategorySummary(
|
|||||||
* (critical|high → red, medium|low → yellow, info/none → green).
|
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||||
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||||
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
* - 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
|
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||||
* findings or tests appear in the summary.
|
* findings or tests appear in the summary.
|
||||||
@@ -44,7 +47,10 @@ object Verdicts {
|
|||||||
private fun isInconclusiveTest(s: TestStatus) =
|
private fun isInconclusiveTest(s: TestStatus) =
|
||||||
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||||
|
|
||||||
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
fun derive(tests: List<Test>, findings: List<Finding>): Summary =
|
||||||
|
derive(tests, findings, Constraints())
|
||||||
|
|
||||||
|
fun derive(tests: List<Test>, findings: List<Finding>, constraints: Constraints): Summary {
|
||||||
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||||
val findingsByCat = findings.groupBy { it.category }
|
val findingsByCat = findings.groupBy { it.category }
|
||||||
val categories = (testsByCat.keys + findingsByCat.keys)
|
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)
|
return Summary(overall = overall, categories = perCat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ class StunProbe(
|
|||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
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 ->
|
DatagramSocket().use { sock ->
|
||||||
sock.soTimeout = 3000
|
sock.soTimeout = 3000
|
||||||
val localPort = sock.localPort
|
val localPort = sock.localPort
|
||||||
|
|||||||
Reference in New Issue
Block a user