app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework
IPv6: absence is no longer a defect. If the network never provisioned v6 (no global address, no ::/0 route) the finding is ipv6.not_offered at INFO (green) — most networks are still IPv4-only. If v6 IS advertised but doesn't work, it's ipv6.broken at MEDIUM (yellow), because half-working v6 stalls connections. Verified on-device: our LAN advertises a v6 default route with no path, and now reports ipv6.broken. Autorun: `am start ... --ez autorun true` runs the suite immediately, POSTs the report to the collection endpoint, shows the result for 3s and finishes the activity (stays open if the upload failed). receiver.py gains POST /report + GET /reports + GET /report/<name>. Verified end to end: one adb command, report retrieved over HTTP, app closed itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
483de5ca54
commit
dc094d1631
@@ -430,3 +430,25 @@ Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent.
|
|||||||
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
|
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
|
||||||
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
|
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
|
||||||
throwaway connected socket). 7 tests/run.
|
throwaway connected socket). 7 tests/run.
|
||||||
|
|
||||||
|
## App: autorun mode + IPv6 severity rework (2026-08-01)
|
||||||
|
**IPv6 is no longer treated as a defect just for being absent.** The finding now depends on
|
||||||
|
whether the network actually provisioned IPv6 (a global v6 address or a `::/0` route):
|
||||||
|
- not provisioned → `ipv6.not_offered`, severity **INFO → green**. Most networks are still
|
||||||
|
IPv4-only and that is not a fault.
|
||||||
|
- provisioned but ICMPv6 fails → `ipv6.broken`, severity **MEDIUM → yellow**. Half-configured
|
||||||
|
IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a
|
||||||
|
v6 default route with no working path, so it correctly reports `ipv6.broken`.
|
||||||
|
|
||||||
|
**Autorun mode** — one adb command runs a full measurement unattended and collects the result
|
||||||
|
without any UI tapping or adb round-trip:
|
||||||
|
```
|
||||||
|
adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
curl http://<fmr>/reports # list
|
||||||
|
curl http://<fmr>/report/<name> # fetch
|
||||||
|
```
|
||||||
|
The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py
|
||||||
|
gained `POST /report`, `GET /reports`, `GET /report/<name>`), shows the result for 3 s, then
|
||||||
|
finishes itself — leaving the device as it was found. On upload failure it stays open so the
|
||||||
|
error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app
|
||||||
|
android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
||||||
|
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release { isMinifyEnabled = false }
|
release { isMinifyEnabled = false }
|
||||||
|
|||||||
@@ -40,9 +40,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
val vm: RunViewModel = viewModel()
|
val vm: RunViewModel = viewModel()
|
||||||
|
// Automation entry point:
|
||||||
|
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
// starts a run immediately and uploads the report, so an unattended
|
||||||
|
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||||
|
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
|
if (autorun) vm.run(upload = true)
|
||||||
|
}
|
||||||
|
// In autorun the app is a batch job: once the run is done AND the upload
|
||||||
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
|
// was found. On failure it stays open so the error is visible.
|
||||||
|
val st = vm.state
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
|
||||||
|
if (autorun && !st.running && st.document != null &&
|
||||||
|
st.uploadStatus?.startsWith("uploaded") == true
|
||||||
|
) {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
EcholotScreen(
|
EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
onRun = vm::run,
|
onRun = { vm.run() },
|
||||||
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,6 +111,10 @@ private fun EcholotScreen(state: UiState, onRun: () -> Unit, onExport: (Measurem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.uploadStatus?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
|
||||||
|
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
|
||||||
|
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
|
||||||
|
*/
|
||||||
|
object ReportUploader {
|
||||||
|
|
||||||
|
data class Result(val ok: Boolean, val detail: String)
|
||||||
|
|
||||||
|
fun upload(doc: MeasurementDocument): Result {
|
||||||
|
val url = BuildConfig.REPORT_UPLOAD_URL
|
||||||
|
if (url.isBlank()) return Result(false, "no upload URL configured")
|
||||||
|
return try {
|
||||||
|
val body = Report.toJson(doc).toByteArray()
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
|
||||||
|
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||||
|
?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
conn.disconnect()
|
||||||
|
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result(false, t.message ?: t.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ data class UiState(
|
|||||||
val running: Boolean = false,
|
val running: Boolean = false,
|
||||||
val currentStep: String? = null,
|
val currentStep: String? = null,
|
||||||
val document: MeasurementDocument? = null,
|
val document: MeasurementDocument? = null,
|
||||||
|
/** Result line of an automated upload (autorun mode); null when not attempted. */
|
||||||
|
val uploadStatus: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,12 +52,22 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
override fun monoNs(): Long = System.nanoTime() - originNanos
|
override fun monoNs(): Long = System.nanoTime() - originNanos
|
||||||
}
|
}
|
||||||
|
|
||||||
fun run() {
|
/**
|
||||||
|
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the
|
||||||
|
* collection endpoint so an unattended run can be retrieved without adb.
|
||||||
|
*/
|
||||||
|
fun run(upload: Boolean = false) {
|
||||||
if (state.running) return
|
if (state.running) return
|
||||||
state = state.copy(running = true, currentStep = "starting", document = null)
|
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val doc = withContext(Dispatchers.IO) { measure() }
|
val doc = withContext(Dispatchers.IO) { measure() }
|
||||||
state = UiState(running = false, currentStep = null, document = doc)
|
var status: String? = null
|
||||||
|
if (upload) {
|
||||||
|
state = state.copy(currentStep = "uploading report")
|
||||||
|
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||||
|
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
||||||
|
}
|
||||||
|
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +122,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
tests.add(shizukuTest)
|
tests.add(shizukuTest)
|
||||||
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
||||||
|
|
||||||
val findings = deriveFindings(tests)
|
val findings = deriveFindings(tests, networks)
|
||||||
val summary = Verdicts.derive(tests, findings)
|
val summary = Verdicts.derive(tests, findings)
|
||||||
|
|
||||||
return MeasurementDocument(
|
return MeasurementDocument(
|
||||||
@@ -134,9 +146,21 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal first-pass findings from device-tier evidence; the full findings registry grows
|
/**
|
||||||
* with the test suite. */
|
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
||||||
private fun deriveFindings(tests: List<Test>): List<Finding> {
|
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
||||||
|
*/
|
||||||
|
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
|
||||||
|
networks.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" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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> {
|
||||||
val out = ArrayList<Finding>()
|
val out = ArrayList<Finding>()
|
||||||
val ids = RunIds()
|
val ids = RunIds()
|
||||||
for (t in tests) {
|
for (t in tests) {
|
||||||
@@ -202,15 +226,32 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
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)) {
|
||||||
out.add(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
id = ids.uuid(), code = "ipv6.no_icmp_path", category = Category.IPV6,
|
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
|
||||||
severity = Severity.LOW, confidence = Confidence.MEDIUM,
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
title = "No IPv6 ICMP path on any active network",
|
title = "IPv6 is configured but not working",
|
||||||
description = "ICMPv6 echo got no reply on any active network — this network has no working IPv6 path (or filters ICMPv6).",
|
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
|
||||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
|
||||||
|
severity = Severity.INFO, 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.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import time
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
STATE_PATH = os.environ.get("STATE_PATH", "/run/echolot-adb-beacon.json")
|
STATE_PATH = os.environ.get("STATE_PATH", "/run/echolot-adb-beacon.json")
|
||||||
|
REPORT_DIR = os.environ.get("REPORT_DIR", "/tmp/echolot-reports")
|
||||||
SECRET = os.environ.get("ECHOLOT_BEACON_SECRET", "")
|
SECRET = os.environ.get("ECHOLOT_BEACON_SECRET", "")
|
||||||
BIND = os.environ.get("BEACON_BIND", "0.0.0.0")
|
BIND = os.environ.get("BEACON_BIND", "0.0.0.0")
|
||||||
PORT = int(os.environ.get("BEACON_PORT", "9099"))
|
PORT = int(os.environ.get("BEACON_PORT", "9099"))
|
||||||
@@ -55,6 +56,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# Dev convenience: serve a staged APK so a device (which can only reach
|
# Dev convenience: serve a staged APK so a device (which can only reach
|
||||||
# this host on 443) can pull it via its own curl — more resilient than
|
# this host on 443) can pull it via its own curl — more resilient than
|
||||||
# adb's sustained transport over a flaky wifi. Path from APK_PATH.
|
# adb's sustained transport over a flaky wifi. Path from APK_PATH.
|
||||||
|
if p == "/reports":
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(REPORT_DIR), reverse=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
names = []
|
||||||
|
return self._send(200, json.dumps(names).encode())
|
||||||
|
if p.startswith("/report/"):
|
||||||
|
name = os.path.basename(p[len("/report/"):])
|
||||||
|
try:
|
||||||
|
with open(os.path.join(REPORT_DIR, name), "rb") as f:
|
||||||
|
return self._send(200, f.read())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return self._send(404, b'{"error":"no such report"}')
|
||||||
if p == "/apk":
|
if p == "/apk":
|
||||||
apk = os.environ.get("APK_PATH", "/tmp/echolot-app.apk")
|
apk = os.environ.get("APK_PATH", "/tmp/echolot-app.apk")
|
||||||
try:
|
try:
|
||||||
@@ -65,7 +79,27 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(404, b'{"error":"not found"}')
|
return self._send(404, b'{"error":"not found"}')
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if self.path.rstrip("/") != "/beacon":
|
p = self.path.rstrip("/")
|
||||||
|
# Measurement-report drop box: the app's autorun mode POSTs its run JSON here so a
|
||||||
|
# test run needs no adb at all (start it once, collect the result over HTTP).
|
||||||
|
if p == "/report":
|
||||||
|
if SECRET and self.headers.get("X-Beacon-Secret") != SECRET:
|
||||||
|
return self._send(403, b'{"error":"bad secret"}')
|
||||||
|
n = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = self.rfile.read(n)
|
||||||
|
try:
|
||||||
|
doc = json.loads(body)
|
||||||
|
dev = doc.get("run", {}).get("device", {}).get("model", "device")
|
||||||
|
rid = doc.get("run", {}).get("id", "unknown")[:8]
|
||||||
|
except Exception:
|
||||||
|
dev, rid = "device", "unparsed"
|
||||||
|
os.makedirs(REPORT_DIR, exist_ok=True)
|
||||||
|
name = f"{int(time.time())}-{dev}-{rid}.json"
|
||||||
|
with open(os.path.join(REPORT_DIR, name), "wb") as f:
|
||||||
|
f.write(body)
|
||||||
|
return self._send(200, json.dumps({"stored": name, "bytes": len(body)}).encode())
|
||||||
|
|
||||||
|
if p != "/beacon":
|
||||||
return self._send(404, b'{"error":"not found"}')
|
return self._send(404, b'{"error":"not found"}')
|
||||||
if SECRET and self.headers.get("X-Beacon-Secret") != SECRET:
|
if SECRET and self.headers.get("X-Beacon-Secret") != SECRET:
|
||||||
return self._send(403, b'{"error":"bad secret"}')
|
return self._send(403, b'{"error":"bad secret"}')
|
||||||
|
|||||||
Reference in New Issue
Block a user