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:
mrambossek
2026-08-01 23:20:52 +02:00
co-authored by Claude Opus 5
parent e0428b4c84
commit 4aaaa5f5d4
7 changed files with 99 additions and 5 deletions
@@ -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.
@@ -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
@@ -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"