server: a non-admin account can manage its own uploads
Signing in and being allowed to administer the server were the same question: the OIDC callback refused a session outright to anyone outside the admin group. A legitimate user could authenticate, be told what they could not do, and be left with no way to see or delete the data their own devices had uploaded. They are separate questions now. Everyone who authenticates gets a session; the admin flag rides inside the MAC'd payload, so promoting yourself means forging a signature rather than editing a cookie, and a role that does not parse fails closed to "user". Pages scope themselves through visibleDevices/mayTouchRun rather than filtering individually — per-page scoping is what the next page added will be missing, and that failure is silent, since a listing that leaks other people's uploads looks exactly like one that does not. Someone else's run answers 404, not 403: a distinguishable refusal would confirm the run exists. Revoking devices and minting enrolment tokens affect the whole server and stay behind adminOnly at the route table, where someone looking for who-may-do-what will actually find it. Ownership is re-read per request instead of captured at sign-in, so unlinking an account takes effect immediately rather than at session expiry. Tests cover that, plus the degenerate case of an empty subject, which must own nothing rather than everything with an empty account id. Also: attribute the ICMPv6 finding per network. It compared "is IPv6 configured anywhere on this device" against "did any network answer", which on a phone reports IPv6-is-broken about a network where IPv6 was never configured. network_ref is null on every test, so the probe now records per-network outcomes structurally rather than as prose a finding would have to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7eaf0c4190
commit
7a5004f293
@@ -387,17 +387,54 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
||||
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
||||
* Was IPv6 provisioned on the network a test actually ran over?
|
||||
*
|
||||
* This deliberately asks about one network rather than about the device. Answering "does any
|
||||
* network here have IPv6" produces a real false positive on a phone, and it is not hypothetical:
|
||||
* an IPv4-only wifi with working cellular alongside it reports "IPv6 is configured, but ICMPv6
|
||||
* gets no reply" — configured on cellular, pinged over wifi, and the two never met.
|
||||
*
|
||||
* A global (non-link-local) address or a v6 default route means the network claims to offer
|
||||
* IPv6; link-local only does not count, since every interface has one.
|
||||
*/
|
||||
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
|
||||
networks.any { n ->
|
||||
private fun ipv6Provisioned(
|
||||
networks: List<app.echo_lot.measurement.Network>,
|
||||
networkRef: String?,
|
||||
): Boolean {
|
||||
// No reference means the test was not per-network; fall back to the device-wide reading
|
||||
// rather than silently reporting nothing.
|
||||
val scope = networks.filter { networkRef == null || it.id == networkRef }
|
||||
return scope.any { n ->
|
||||
n.link.addresses.any { a ->
|
||||
a.addr.contains(':') &&
|
||||
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
||||
!a.addr.startsWith("::1")
|
||||
} || n.link.routes.any { it.dst == "::/0" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-network ICMP outcomes, keyed by network id.
|
||||
*
|
||||
* Reads the structured evidence the probe records rather than its prose detail — a finding
|
||||
* that depended on the wording of a human-readable string would break silently the first time
|
||||
* that wording improved.
|
||||
*/
|
||||
private fun icmpResults(t: Test): Map<String, Boolean> {
|
||||
val out = HashMap<String, Boolean>()
|
||||
val ev = t.evidence ?: return out
|
||||
for ((_, v) in ev) {
|
||||
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||
val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue
|
||||
val ok = (o["ok"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
|
||||
out[ref] = ok
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Human-facing name for the network a test ran over; falls back to something readable. */
|
||||
private fun ifaceOf(networks: List<app.echo_lot.measurement.Network>, ref: String?): String =
|
||||
networks.firstOrNull { it.id == ref }?.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||
|
||||
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
|
||||
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
||||
@@ -526,31 +563,55 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
|
||||
// and that is not a defect. What IS a defect is IPv6 that the network claims to
|
||||
// provide (a global address or a default route from RA/DHCPv6) but that does not
|
||||
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
|
||||
// depends on whether v6 was provisioned at all.
|
||||
if (ipv6Provisioned(networks)) {
|
||||
if (t.type == TestType.ICMP_PING6) {
|
||||
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only, and
|
||||
// that is not a defect. What IS a defect is IPv6 the network claims to provide (a
|
||||
// global address or a default route from RA/DHCPv6) that does not work: that causes
|
||||
// Happy-Eyeballs delays, timeouts and hangs.
|
||||
//
|
||||
// Judged per network, from the per-network evidence rather than the aggregate
|
||||
// status. The aggregate can only say "some network answered", and on a phone with
|
||||
// wifi and cellular up at once that is how "IPv6 is configured but gets no reply"
|
||||
// ends up describing a network where IPv6 was never configured in the first place.
|
||||
val results = icmpResults(t)
|
||||
var anyV6Network = false
|
||||
for (n in networks) {
|
||||
val provisioned = ipv6Provisioned(networks, n.id)
|
||||
if (provisioned) anyV6Network = true
|
||||
val ok = results[n.id] ?: continue
|
||||
if (!provisioned || ok) continue
|
||||
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity, confidence = Confidence.MEDIUM,
|
||||
title = "IPv6 is configured, but ICMPv6 gets no reply",
|
||||
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 echo got no reply on any network. That has two explanations which look identical from here: IPv6 is broken, or ICMPv6 is filtered while IPv6 itself works. Filtering is common and is a fault in its own right — it breaks Path MTU Discovery, so large packets vanish rather than being reported as too big.",
|
||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||
confidence = Confidence.MEDIUM,
|
||||
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
|
||||
description = "$where advertises IPv6 (a global address and/or a " +
|
||||
"default route), but ICMPv6 echo got no reply over it. That has " +
|
||||
"two explanations which look identical from here: IPv6 is broken, " +
|
||||
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
|
||||
"common and is a fault in its own right — it breaks Path MTU " +
|
||||
"Discovery, so large packets vanish rather than being reported as " +
|
||||
"too big.",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
)
|
||||
} else {
|
||||
}
|
||||
if (!anyV6Network) {
|
||||
// Said once for the device, not once per interface: "this network is IPv4-only"
|
||||
// repeated per interface reads as several problems instead of one observation.
|
||||
out.add(
|
||||
Finding(
|
||||
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
||||
category = FindingRegistry.V6_NOT_OFFERED.category,
|
||||
severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH,
|
||||
severity = FindingRegistry.V6_NOT_OFFERED.severity,
|
||||
confidence = Confidence.HIGH,
|
||||
title = "IPv4-only network (no IPv6 offered)",
|
||||
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
|
||||
description = "No IPv6 address or default route was provisioned on " +
|
||||
"any active network, so IPv6 tests could not run. This is normal " +
|
||||
"— many networks are still IPv4-only and it is not a fault.",
|
||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -40,24 +40,35 @@ class IcmpProbe(
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
val perNetwork = LinkedHashMap<String, String>()
|
||||
val perNetwork = LinkedHashMap<String, Triple<String?, Boolean, String>>()
|
||||
var anyOk = false
|
||||
val rtts = ArrayList<Double>()
|
||||
|
||||
// Default network first, then each active network explicitly.
|
||||
attempt(null).let { (ok, detail, rtt) ->
|
||||
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||
perNetwork["default"] = Triple(null, ok, detail); if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||
}
|
||||
for (e in entries) {
|
||||
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||
val (ok, detail, rtt) = attempt(e.handle)
|
||||
perNetwork[label] = detail
|
||||
perNetwork[label] = Triple(e.model.id, ok, detail)
|
||||
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||
}
|
||||
|
||||
// Per-network results are recorded structurally, not just as prose. The aggregate status
|
||||
// can only say "some network answered"; a finding needs to know *which* network failed,
|
||||
// and recovering that by parsing a human-readable detail string would be a trap waiting to
|
||||
// spring the first time the wording changes.
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
put("target", target)
|
||||
for ((k, v) in perNetwork) put(k, v)
|
||||
for ((label, r) in perNetwork) {
|
||||
val (netId, ok, detail) = r
|
||||
put(label, buildJsonObject {
|
||||
netId?.let { put("network_ref", it) }
|
||||
put("ok", ok)
|
||||
put("detail", detail)
|
||||
})
|
||||
}
|
||||
}
|
||||
val metrics: JsonObject = buildJsonObject {
|
||||
put("networks_ok", rtts.size)
|
||||
|
||||
Reference in New Issue
Block a user