Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 9d6572bc33 compat: fix the too-new message's grammar, add a live gate test
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 30s
The generated refusal read "point at a app within range". Also adds
LiveCompatTest, which checks the half a unit test cannot reach: that two
independently-built artifacts agree on the window, that the profile stays
readable for a version the server refuses, and that both bounds are enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:38:45 +02:00
mrambossekandClaude Fable 5 0c5b021b63 compat: SemVer version windows between app and server
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - GET /v1/profile is never gated. It is where a refused client learns which
    version it needs; gating it leaves the user with a network error instead of
    an answer, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:36:34 +02:00
mrambossekandClaude Fable 5 277e33da75 chore: drop core-measurement/bin from the index too
deploy-site / deploy (push) Failing after 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:48 +02:00
mrambossekandClaude Fable 5 14e5fad1b2 engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.

Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:38 +02:00
mrambossekandClaude Fable 5 ce1aaa332a server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:45:43 +02:00
66 changed files with 4198 additions and 996 deletions
+48 -4
View File
@@ -32,10 +32,25 @@ Keep prober result IDs aligned with the measurement-schema test-type registry.
## Versioning
Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps
for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the
Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release +
registry image; the server on fmr can `--self-update` from those releases.
Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental
work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are
namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI →
binaries + Gitea release + registry image; the server on fmr can `--self-update` from those
releases.
The app's version lives once, as `appVersionName` in `app/build.gradle.kts`; **`versionCode` is
derived from it** (`major*1e6 + minor*1e4 + patch*10`). Never set it by hand — a second number a
human has to remember to bump eventually disagrees with the first.
**Versions are load-bearing** (probe-protocol.md §8): the server refuses apps outside its window
with `426`, and the app refuses servers outside its own. Two axes, kept separate:
- `protocol_version`*can* they talk. The correctness axis; below 1.0.0 the **minor** is the
breaking axis.
- release-version window — *may* they, per policy. `[min, max)`, bounds at breaking boundaries so
a patch never strands a fleet. Client bounds: `Compat.kt`. Server: `ECHOLOT_MIN/MAX_APP_VERSION`.
Raise a minimum only when older peers are actively harmful, and say why in the constant's comment.
`GET /v1/profile` must stay ungated — it is how a refused client learns what it needs.
## Layout
@@ -52,6 +67,29 @@ echolot-prober/ the capability prober (self-contained Gradle buil
ui/ProberScreen.kt result cards colored by verdict
```
## Client modules (echolot-app/)
Pure Kotlin/JVM where possible, so the interesting logic is unit-testable without a device and can
be exercised against the live server from the PC:
- `core-protocol` — control plane (pinned TLS) + ELT1 UDP data plane. **One `ProbeSession` per
server session, for its whole lifetime**: a second one restarts sequence numbers, the server's
anti-replay window discards every packet, and granted sends then target the closed socket.
- `core-measurement` — the schema types. `core-engine` — composes probes into documents.
- `core-privacy` — the §8 anonymizer (`full` / `balanced` / `strict`). Field classification lives
in one table (`Classification.kt`); keep it there rather than annotating models.
- `core-archive` — on-device run storage + retention. `enabled` is separate from the three
ceilings: all-zeros means "no limits", not "keep nothing".
**The local archive keeps the unredacted document; anonymization happens per upload, on the way
out.** Never redact what is stored locally.
### Live testing without a device
`echolot-app/scripts/test-fmr.sh [gradle-task] [test-filter]` mints an enrollment token over SSH,
enrolls, computes the SPKI pin from the served cert and runs a `Live*Test` against fmr. This covers
the whole server-facing vertical (granted sends, downstream MTU, uploads) with no phone involved —
use it before asking the user to test on hardware.
## Conventions
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway).
@@ -107,6 +145,12 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh
port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
around this (or USB, if ever available).
- **Empty-jar race with the IDE.** VSCodium's Java/Kotlin extension runs its own Gradle daemon on
the same project; when it overlaps a CLI build, a module's `build/libs/*.jar` can end up
containing only a manifest, and Gradle then considers `jar` up-to-date. Dependent modules fail
with "Unresolved reference" on symbols that plainly exist. Fix: `rm -f <module>/build/libs/*.jar`
and re-run the `jar` task. Suspect this whenever a reference resolves in one module but not in
its consumer.
- As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
Android Studio, its `jbr` directory works as `JAVA_HOME`.
+78
View File
@@ -543,3 +543,81 @@ Best available behavior, now implemented: the banner still opens Shizuku, but th
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
debugging must be enabled first for Shizuku's wireless start to work at all.
### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
The client can measure a round trip and the largest packet it can *send*. It cannot measure the
largest packet it can *receive*, or downstream-only loss — those need the server to push, which is
exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
- **`session.Grant`** — created per action, bound at creation to the session's *observed*
data-plane source (no grant without a verified destination), clamped to server limits, with a
byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.
- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss,
reordering and inter-arrival spacing.
- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size
that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only
says whether fragments get through — a different fact, and the reason the schema has both
`mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the
startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by
our kernel is never read as a limit of the client's path.
Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery
up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a
3000 µs send interval.
#### Two bugs this shook out, both invisible in a single-homed lab
1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two
IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind
list. A train for a session established on `.150` left from `.151` and every packet was dropped
by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the
client saw none — reported as *100 % downstream loss*, a confident measurement of something
that never happened. Sessions now record which of our own bound addresses received their
traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the
family fallback.
2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at
zero client-side while the server's anti-replay window keeps counting, so every packet is
discarded as a replay — and because the server then never records the new source, the grant
still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole
run; `ProbeSession`'s doc comment states the constraint.
### Run archive, anonymizer and uploads (2026-08-01)
Three pieces, deliberately separate:
- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can
inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is
enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three
ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto
all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.
- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes
nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a
public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour
inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics,
statuses and finding codes. Pseudonyms are consistent within a document and — by default — not
across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for
people diffing their own history. Classification is one readable table, not annotations spread
across modules. 14 tests, each pinning a property someone's privacy depends on.
- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max
runs per device, and the *least* anonymization accepted. The profile advertises all of it so the
app presents the choice honestly instead of discovering the rules by being rejected. `account`
refuses today rather than falling back to anonymous: picking the strict setting before OIDC
lands must not silently mean the loose one.
**The archive holds the unredacted document; redaction happens on the way out, per upload.** The
local archive is the user's own data on their own device, and redacting it would destroy exactly
the detail that makes a week-old run worth keeping.
App-side: settings screen (archive limits, privacy level with a plain-language description of what
each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether
each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer
the user cannot inspect is only a promise.
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the
SSDP neighbour name and the free-text note are absent from what the server stores while the
finding code and the metrics survive, then deleted it.
### Still open
- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval.
- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
against the live server. On-device verification is the next step.
+65 -2
View File
@@ -192,14 +192,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference.
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.
## 8. Cross-references to the measurement schema
## 8. Version compatibility
Both artifacts are versioned with **SemVer**: the Go server (`server-vX.Y.Z` tags) and the Android
app (`versionName`; `versionCode` is derived from it, never maintained separately). Two independent
things are checked, and conflating them is the mistake this section exists to prevent.
### 8.1 Protocol version — *can* these builds talk?
`protocol_version` is the version of **this document**. It is advertised in the profile
(`compat.protocol_version`) and is the correctness axis: a peer in a different breaking series
cannot be talked to, whatever its release version says. Below `1.0.0` the **minor** is the breaking
axis (SemVer §4); at and above it, the major is. A patch bump of the protocol never splits a fleet.
### 8.2 Release-version window — *should* they, per policy?
Each side declares the range of peer release versions it will work with, as `[min, max)`
**minimum inclusive, maximum exclusive**, because the useful bound is always "the version that
broke it" and writing that literally is unambiguous. An empty maximum means unbounded.
The server advertises its window and enforces it:
```jsonc
"compat": {
"protocol_version": "1.0.0",
"schema_version": "1.0.0",
"app_min": "0.2.0",
"app_max": "1.0.0" // exclusive; "" = no upper bound
}
```
Operators override it with `ECHOLOT_MIN_APP_VERSION` / `ECHOLOT_MAX_APP_VERSION` (or
`--min-app-version` / `--max-app-version`). A malformed bound is **fatal at startup**, not ignored:
a typo must not silently disable a restriction the operator meant to set.
The app sends its version on every control-plane request:
```
X-Echolot-App-Version: 0.2.0
```
and carries its own bounds for the server (`MIN_SERVER` / `MAX_SERVER` in `Compat.kt`). It checks
the profile in **both** directions — is the server in our range, and are we in the server's — so a
mismatch is reported before a run starts rather than discovered halfway through one.
### 8.3 Rules
1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs.
Gating it leaves the user with a network error instead of an answer, defeating the check.
2. **Refusal is `426 Upgrade Required`**, with a body naming both versions and the accepted window:
```json
{ "error": "app 0.1.0 is older than this build supports (needs >= 0.2.0, < 1.0.0). Update the app.",
"app_version": "0.1.0", "accepts_app": ">= 0.2.0, < 1.0.0",
"server_version": "0.5.0", "protocol_version": "1.0.0" }
```
3. **An unparseable or absent version is `unknown`, and is allowed.** Development builds report
`dev`, and a client too old to send the header cannot be identified anyway. The check exists to
turn confusing failures into clear ones; refusing what it cannot identify does the opposite.
4. **Bounds move at breaking boundaries, not at releases.** Shipping a patch must never require
editing a range. A minimum is raised only when older peers are actually harmful — e.g. the app
requires server `>= 0.4.2` because earlier multi-homed servers sent granted traffic from an
address the session never used, which the client measured as 100 % downstream loss. A
confidently wrong measurement is worse than a refused one.
## 9. Cross-references to the measurement schema
- Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2).
- `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note).
- Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`.
- `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`.
## 9. Open items
## 10. Open items
1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit.
2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design.
+21 -2
View File
@@ -8,6 +8,20 @@ plugins {
alias(libs.plugins.kotlin.serialization)
}
// The app's version is SemVer and lives here, once. versionCode is derived from it rather than
// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number
// that a human has to remember to bump is a number that eventually disagrees with the first — and
// the version is now load-bearing, since the server decides whether to serve us by it.
//
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.0"
fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
return major * 1_000_000 + minor * 10_000 + patch * 10
}
android {
namespace = "app.echo_lot.app"
compileSdk = 36
@@ -16,12 +30,15 @@ android {
applicationId = "app.echo_lot.app"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionCode = versionCodeOf(appVersionName)
versionName = appVersionName
// 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\"")
// The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's
// parser anyway — sent to servers so they can apply their compatibility window.
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
}
buildTypes {
release { isMinifyEnabled = false }
@@ -48,6 +65,8 @@ dependencies {
implementation(project(":core-engine"))
implementation(project(":core-probe"))
implementation(project(":core-shizuku"))
implementation(project(":core-privacy"))
implementation(project(":core-archive"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.echo_lot.archive.ArchivedRun
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Archived runs, newest first.
*
* Each row states plainly whether the run left the device, because "is this backed up / did I
* share this?" is the question a history list actually gets asked.
*/
@Composable
fun HistoryScreen(
runs: List<ArchivedRun>,
status: String?,
onOpen: (String) -> Unit,
onUpload: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("History", style = MaterialTheme.typography.titleLarge)
}
status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (runs.isEmpty()) {
Text(
"No archived runs yet. Finished runs are kept here automatically unless you turn " +
"archiving off in settings.",
style = MaterialTheme.typography.bodyMedium,
)
return@Column
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(runs, key = { it.id }) { r ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
r.verdict?.uppercase() ?: "",
color = verdictTint(r.verdict),
style = MaterialTheme.typography.titleMedium,
)
Text(
" " + humanTime(r.savedAtEpochMs),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}",
style = MaterialTheme.typography.bodySmall,
)
Text(
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}"
else "on this device only",
style = MaterialTheme.typography.bodySmall,
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onOpen(r.id) }) { Text("Export") }
TextButton(onClick = { onUpload(r.id) }) {
Text(if (r.uploaded) "Upload again" else "Upload")
}
TextButton(onClick = { onDelete(r.id) }) { Text("Delete") }
}
}
}
}
}
}
}
private fun verdictTint(v: String?): Color = when (v?.lowercase()) {
"green", "ok", "pass" -> Color(0xFF7FD17F)
"yellow", "warn" -> Color(0xFFE0C060)
"red", "fail" -> Color(0xFFE07070)
else -> Color(0xFFBBBBBB)
}
private val stamp: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs))
@@ -18,6 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -26,9 +30,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.viewmodel.compose.viewModel
import app.echo_lot.measurement.*
/** The app's three top-level screens. */
private enum class Screen { RUN, HISTORY, SETTINGS }
class MainActivity : ComponentActivity() {
private val permissionLauncher =
@@ -41,13 +50,17 @@ class MainActivity : ComponentActivity() {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.background) {
val vm: RunViewModel = viewModel()
// Three flat screens, so a plain state variable beats a navigation library:
// there is no back stack to model beyond "return to the run screen".
var screen by remember { mutableStateOf(Screen.RUN) }
var preview by remember { mutableStateOf<String?>(null) }
// 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)
if (autorun) vm.run(devUpload = 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
@@ -61,7 +74,44 @@ class MainActivity : ComponentActivity() {
finish()
}
}
EcholotScreen(
when (screen) {
Screen.SETTINGS -> SettingsScreen(
settings = vm.settings,
archivedRuns = vm.state.history.size,
archivedBytes = vm.archivedBytes(),
onApplyRetention = vm::applyRetention,
onDeleteAll = vm::deleteAllRuns,
onPreviewUpload = {
// Preview the newest run, since that is the one the user just made
// and the one they are deciding about.
vm.state.history.firstOrNull()?.let { r ->
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
}
},
onCheckServer = vm::checkServer,
serverStatus = vm.state.archiveStatus,
onBack = { screen = Screen.RUN },
)
Screen.HISTORY -> HistoryScreen(
runs = vm.state.history,
status = vm.state.archiveStatus,
onOpen = { id ->
lifecycleScope.launch {
vm.readRun(id)?.let { text ->
startActivity(
Intent.createChooser(
Report.shareJson(this@MainActivity, id, text),
"Export Echolot run",
)
)
}
}
},
onUpload = vm::uploadRun,
onDelete = vm::deleteRun,
onBack = { screen = Screen.RUN },
)
Screen.RUN -> EcholotScreen(
state = vm.state,
onRun = { vm.run() },
onCancel = vm::cancel,
@@ -86,7 +136,13 @@ class MainActivity : ComponentActivity() {
}
},
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
)
onOpenSettings = { screen = Screen.SETTINGS },
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
)
}
preview?.let { text ->
UploadPreviewDialog(text) { preview = null }
}
}
}
}
@@ -123,6 +179,8 @@ private fun EcholotScreen(
onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit,
onExport: (MeasurementDocument) -> Unit,
onOpenSettings: () -> Unit,
onOpenHistory: () -> Unit,
) {
Column(
Modifier
@@ -135,8 +193,15 @@ private fun EcholotScreen(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess",
color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
}
TextButton(onClick = onOpenHistory) { Text("History") }
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
// only users who actually use it get reminded that it must be running.
@@ -185,6 +250,10 @@ private fun EcholotScreen(
}
}
state.archiveStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
state.uploadStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -338,3 +407,24 @@ private fun Dot(color: Color) {
private fun SectionTitle(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
}
/**
* Shows the exact JSON an upload would send.
*
* This exists because an anonymizer the user cannot inspect is just a promise. Being able to
* read the outgoing document — and find their own SSID absent from it — is what makes the
* privacy setting checkable rather than merely stated.
*/
@Composable
private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
title = { Text("This is what would be uploaded") },
text = {
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
}
},
)
}
@@ -17,10 +17,18 @@ object Report {
fun toJson(doc: MeasurementDocument): String =
json.encodeToString(MeasurementDocument.serializer(), doc)
fun share(ctx: Context, doc: MeasurementDocument): Intent {
fun share(ctx: Context, doc: MeasurementDocument): Intent =
shareJson(ctx, doc.run.id, toJson(doc))
/**
* Shares an already-serialized run — an archived one, whose bytes must go out exactly as
* stored rather than being re-serialized through the model (which would silently drop
* anything a newer schema version added).
*/
fun shareJson(ctx: Context, runId: String, json: String): Intent {
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
val file = File(dir, "echolot-run-${doc.run.id}.json")
file.writeText(toJson(doc))
val file = File(dir, "echolot-run-$runId.json")
file.writeText(json)
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
return Intent(Intent.ACTION_SEND).apply {
type = "application/json"
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import app.echo_lot.archive.ArchivedRun
import app.echo_lot.archive.RunArchive
import app.echo_lot.measurement.MeasurementDocument
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.Compat
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import app.echo_lot.protocol.VersionRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.io.File
import java.security.SecureRandom
/**
* Ties together the three things that happen to a finished run: it gets archived, it may get
* anonymized, and it may get uploaded — in that order, and with the archive always holding the
* *unredacted* document.
*
* That ordering is the important decision. The local archive is the user's own data on their own
* device, and redacting it would destroy exactly the detail that makes a week-old run worth
* keeping; the anonymizer exists for the moment data crosses to someone else's machine. So
* redaction happens on the way out, per upload, and the archive is never the lossy copy.
*/
class RunStore(context: Context, private val settings: Settings) {
private val archive = RunArchive(File(context.filesDir, "runs"))
private val json = Json { encodeDefaults = true; explicitNulls = true }
fun list(): List<ArchivedRun> = archive.list()
fun read(id: String): String? = archive.read(id)
fun delete(id: String) = archive.delete(id)
fun deleteAll(): Int = archive.deleteAll()
fun totalBytes(): Long = archive.totalBytes()
/** Archives a finished run under the user's retention policy. Null when archiving is off. */
fun archive(doc: MeasurementDocument): ArchivedRun? =
archive.save(Report.toJson(doc), settings.retention())
/** Applies retention now — e.g. after the user tightens the limits in settings. */
fun purgeNow() = archive.purge(settings.retention())
/**
* Produces exactly the bytes an upload would send, so the UI can show the user their own
* document as the server will see it *before* it goes. "Preview what you're about to share"
* is the only honest way to present an anonymizer: its correctness is not something a user
* should have to take on faith.
*/
fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String {
val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull()
?: return docJson
return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed))
}
private fun salt(): Salt =
if (settings.stableSalt) Salt.stable(settings.saltSecret())
else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) })
sealed interface UploadOutcome {
data class Sent(val serverName: String, val detail: String) : UploadOutcome
/** The operator's policy says no. Not retryable, and not the user's fault. */
data class Refused(val reason: String) : UploadOutcome
/**
* The two builds do not go together. Kept apart from [Refused] and [Failed] because the
* remedy is different and specific — install a particular version — and a message that
* says so is worth more than one that says "upload failed".
*/
data class Incompatible(val reason: String) : UploadOutcome
data class Failed(val detail: String) : UploadOutcome
data object NotConfigured : UploadOutcome
}
private fun client() = ControlClient(
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
)
/**
* Checks the configured server without uploading anything: reachable, pinned, compatible, and
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
*/
fun checkServer(): String {
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
return try {
val profile = client().profile(settings.serverCredential)
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
val head = "${profile.name} · server ${profile.serverVersion} · " +
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
when {
compat.message != null -> head + "\n" + compat.message
else -> {
val uploads = profile.uploads.refusalReason()
?: "uploads accepted (min anonymization: ${profile.uploads.minAnonymization})"
head + "\nCompatible. " + uploads
}
}
} catch (e: VersionRefused) {
"This server will not serve this app: ${e.message}"
} catch (t: Throwable) {
"Could not reach the server: ${t.message ?: t.javaClass.simpleName}"
}
}
/**
* Uploads one archived run to the configured server, redacting first.
*
* The server's advertised minimum wins over the user's preference when it is stricter — a
* server may demand more anonymization than the user chose, never less. Blocking; callers
* run it off the main thread.
*/
fun upload(runId: String): UploadOutcome {
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
return try {
val client = client()
val profile = client.profile(settings.serverCredential)
// Compatibility before policy: an incompatible server may well advertise an upload
// policy it would never actually apply to us.
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
if (!compat.usable) return UploadOutcome.Incompatible(compat.message ?: "incompatible versions")
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
val level = PrivacyLevel.max(
settings.privacyLevel,
PrivacyLevel.fromWire(profile.uploads.minAnonymization),
)
val body = redactedForUpload(docJson, level)
val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}")
} catch (e: VersionRefused) {
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
} catch (e: UploadRefused) {
UploadOutcome.Refused(e.message ?: "refused by the server")
} catch (t: Throwable) {
UploadOutcome.Failed(t.message ?: t.javaClass.simpleName)
}
}
}
@@ -38,6 +38,10 @@ data class UiState(
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
val archiveStatus: String? = null,
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
val shizukuNotice: String? = null,
val shizukuReady: Boolean = false,
@@ -56,6 +60,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState())
private set
val settings = Settings(app)
private val store = RunStore(app, settings)
private var runJob: kotlinx.coroutines.Job? = null
private val stopShizukuObserver: () -> Unit
@@ -92,22 +99,120 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
}
/**
* 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.
* Runs one measurement, then archives it and — if the user has turned that on — uploads it.
*
* [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart
* from the user-facing upload so a debugging convenience can never be mistaken for, or
* silently satisfy, the consent-gated one.
*/
fun run(upload: Boolean = false) {
fun run(devUpload: Boolean = false) {
if (state.running) return
collected.clear()
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
state = state.copy(running = true, currentStep = "starting", document = null,
uploadStatus = null, archiveStatus = null)
runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() }
step("archiving")
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
var archiveStatus = if (archived != null) {
"archived locally (${store.list().size} runs kept)"
} else {
"not archived — archiving is off in settings"
}
var status: String? = null
if (upload) {
if (devUpload) {
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)
if (archived != null && settings.autoUpload) {
state = state.copy(currentStep = "uploading to server")
val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) }
archiveStatus += " · " + describe(outcome)
}
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = status, archiveStatus = archiveStatus,
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
is RunStore.UploadOutcome.Incompatible -> "version mismatch: ${o.reason}"
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
}
// ---- history ---------------------------------------------------------------------
fun refreshHistory() {
viewModelScope.launch {
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteRun(id: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) { store.delete(id) }
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteAllRuns() {
viewModelScope.launch {
val n = withContext(Dispatchers.IO) { store.deleteAll() }
state = state.copy(
history = emptyList(),
archiveStatus = "deleted $n archived run(s)",
)
}
}
/** Uploads one already-archived run on demand, regardless of the auto-upload setting. */
fun uploadRun(id: String) {
viewModelScope.launch {
state = state.copy(archiveStatus = "uploading …")
val outcome = withContext(Dispatchers.IO) { store.upload(id) }
state = state.copy(
archiveStatus = describe(outcome),
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
/** The archived document as stored, for export. */
suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) }
/** The exact bytes an upload would send, for the settings screen's preview. */
suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) {
store.read(id)?.let { store.redactedForUpload(it) }
}
fun archivedBytes(): Long = store.totalBytes()
/** Settings-screen action: report what the configured server is and whether we can use it. */
fun checkServer() {
viewModelScope.launch {
state = state.copy(archiveStatus = "checking server …")
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.checkServer() })
}
}
/** Re-applies retention after the user changes the limits. */
fun applyRetention() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { store.purgeNow() }
state = state.copy(
history = withContext(Dispatchers.IO) { store.list() },
archiveStatus = if (result.isEmpty) "nothing to purge"
else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB",
)
}
}
@@ -122,6 +227,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
archiveStatus = "partial run — not archived",
history = state.history,
)
}
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.content.SharedPreferences
import app.echo_lot.archive.RetentionPolicy
import app.echo_lot.privacy.PrivacyLevel
import java.security.SecureRandom
/**
* User settings for archiving, uploading and anonymization.
*
* Defaults are the conservative reading of "an engineer's tool that still respects the person
* holding it": keep history (that is the point of the archive), never upload without being asked,
* and when uploading, strip identifiers unless the user says this is their own server.
*
* SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at
* the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing.
*/
class Settings(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
// ---- archive ---------------------------------------------------------------------
var archiveEnabled: Boolean
get() = prefs.getBoolean(ARCHIVE_ENABLED, true)
set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply()
/** 0 = no ceiling. */
var maxRuns: Int
get() = prefs.getInt(MAX_RUNS, 100)
set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply()
var maxAgeDays: Int
get() = prefs.getInt(MAX_AGE_DAYS, 90)
set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply()
var maxTotalMb: Int
get() = prefs.getInt(MAX_TOTAL_MB, 64)
set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply()
fun retention(): RetentionPolicy = RetentionPolicy(
enabled = archiveEnabled,
maxRuns = maxRuns,
maxAgeDays = maxAgeDays,
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
// ---- upload ----------------------------------------------------------------------
/**
* Off by default. Measurement data describes the network the user is standing in; sending it
* anywhere is a decision they make, not one they discover after the fact.
*/
var autoUpload: Boolean
get() = prefs.getBoolean(AUTO_UPLOAD, false)
set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply()
/** Anonymization applied before a run leaves the device. Never applied to the local archive. */
var privacyLevel: PrivacyLevel
get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire))
set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply()
/**
* Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as
* last week") and is what someone wants on their own server — but it also produces an
* identifier that links a device's uploads, so it is off unless chosen.
*/
var stableSalt: Boolean
get() = prefs.getBoolean(STABLE_SALT, false)
set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply()
/**
* The device-local secret behind stable pseudonyms. Generated once, never leaves the device,
* and clearing it (via [resetSalt]) breaks the link to everything uploaded before.
*/
fun saltSecret(): ByteArray {
prefs.getString(SALT_SECRET, null)?.let { return hex(it) }
val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) }
prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply()
return fresh
}
fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply()
// ---- server ----------------------------------------------------------------------
var serverUrl: String
get() = prefs.getString(SERVER_URL, "") ?: ""
set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply()
var serverPin: String
get() = prefs.getString(SERVER_PIN, "") ?: ""
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
var serverCredential: String
get() = prefs.getString(SERVER_CRED, "") ?: ""
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
val serverConfigured: Boolean
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
private fun hex(s: String) = ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
private companion object {
const val ARCHIVE_ENABLED = "archive_enabled"
const val MAX_RUNS = "archive_max_runs"
const val MAX_AGE_DAYS = "archive_max_age_days"
const val MAX_TOTAL_MB = "archive_max_total_mb"
const val AUTO_UPLOAD = "auto_upload"
const val PRIVACY_LEVEL = "privacy_level"
const val STABLE_SALT = "stable_salt"
const val SALT_SECRET = "salt_secret"
const val SERVER_URL = "server_url"
const val SERVER_PIN = "server_pin"
const val SERVER_CRED = "server_credential"
}
}
@@ -0,0 +1,230 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import app.echo_lot.privacy.PrivacyLevel
/**
* Archiving, upload and anonymization settings.
*
* The screen is written to make the consequences legible rather than to look tidy: every toggle
* says what it means for the user's data in a sentence, and the privacy levels are described by
* what survives them, because "balanced" on its own tells nobody anything.
*/
@Composable
fun SettingsScreen(
settings: Settings,
archivedRuns: Int,
archivedBytes: Long,
onApplyRetention: () -> Unit,
onDeleteAll: () -> Unit,
onPreviewUpload: () -> Unit,
onCheckServer: () -> Unit,
serverStatus: String?,
onBack: () -> Unit,
) {
// SharedPreferences is not observable, so mirror each value into Compose state and write
// through on change. A dozen scalars; a store with flows would be ceremony for nothing.
var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) }
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
var serverPin by remember { mutableStateOf(settings.serverPin) }
var serverCred by remember { mutableStateOf(settings.serverCredential) }
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("Settings", style = MaterialTheme.typography.titleLarge)
}
// ---- archive ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Archive", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Keep finished runs on this device",
detail = "History is what makes a run comparable later. Archived runs are " +
"stored complete and unredacted — anonymization only applies to uploads.",
checked = archiveEnabled,
) { archiveEnabled = it; settings.archiveEnabled = it }
Text(
"Purge automatically when a run exceeds any of these. 0 turns that limit off.",
style = MaterialTheme.typography.bodySmall,
)
NumberField("Keep at most (runs)", maxRuns) {
maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0
}
NumberField("Delete older than (days)", maxAgeDays) {
maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0
}
NumberField("Keep at most (MB)", maxTotalMb) {
maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0
}
Text(
"$archivedRuns run(s), ${archivedBytes / 1024} kB stored",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onApplyRetention) { Text("Apply now") }
TextButton(onClick = onDeleteAll) { Text("Delete all runs") }
}
}
}
// ---- privacy ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("What leaves the device", style = MaterialTheme.typography.titleMedium)
Text(
"Applied to uploads only. Measurements, verdicts and finding codes survive " +
"every level — only the parts that identify you or your network change.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (level in PrivacyLevel.entries) {
FilterChip(
selected = privacy == level,
onClick = { privacy = level; settings.privacyLevel = level },
label = { Text(level.wire) },
)
}
}
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
Toggle(
label = "Stable pseudonyms across runs",
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
"each time). It also links your uploads together, so leave it off on a " +
"server you don't run yourself.",
checked = stableSalt,
) { stableSalt = it; settings.stableSalt = it }
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
}
}
// ---- upload ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Upload", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Upload finished runs automatically",
detail = "Sends each completed run to the server below, anonymized to the " +
"level above. The server may require more anonymization than you chose; " +
"it can never require less.",
checked = autoUpload,
) { autoUpload = it; settings.autoUpload = it }
OutlinedTextField(
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverCred,
onValueChange = { serverCred = it; settings.serverCredential = it },
label = { Text("Device credential") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onCheckServer) { Text("Check server") }
Text(
" " + if (settings.serverConfigured) "Configured."
else "Uploads stay off until all three fields are set.",
style = MaterialTheme.typography.bodySmall,
)
}
// Version compatibility is checked here rather than discovered mid-run: a server
// that will refuse this build should say so before a measurement is wasted.
serverStatus?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
Text(
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
"${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +
"${app.echo_lot.protocol.Compat.serverRange}.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(24.dp))
}
}
private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
PrivacyLevel.FULL ->
"Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " +
"as measured. Appropriate for a server you run yourself."
PrivacyLevel.BALANCED ->
"Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " +
"prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " +
"ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " +
"192.168.1.1 describes the topology and not the person."
PrivacyLevel.STRICT ->
"Only numbers: test results, metrics and finding codes. No network description, no raw " +
"evidence, no finding text. Nothing left can identify a network."
}
@Composable
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(detail, style = MaterialTheme.typography.bodySmall)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun NumberField(label: String, value: String, onChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) },
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The on-device run archive: measurement documents on disk, with retention.
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
// — the part with edge cases — are unit-testable without a device.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package archive keeps completed measurement runs on the device.
//
// The point of an archive is the second run: "this network was fine on Tuesday" is only
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
// job somebody remembers to write — every save enforces it.
//
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
// write never leaves a half-document that reads as real.
package app.echo_lot.archive
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/** Index entry for one archived run — enough for a history list without opening the documents. */
@Serializable
data class ArchivedRun(
val id: String,
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
@SerialName("started_at") val startedAt: String? = null,
val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0,
val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null,
)
/**
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
* them. Zero disables that limit.
*
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
* a pattern ("it degrades every evening") without turning the phone into an archive of every
* network its owner ever walked past.
*/
@Serializable
data class RetentionPolicy(
/**
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
* a user who turns all the caps off ends up with an empty history.
*/
val enabled: Boolean = true,
@SerialName("max_runs") val maxRuns: Int = 100,
@SerialName("max_age_days") val maxAgeDays: Int = 90,
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
) {
companion object {
/** Archiving off: runs are shown once and never written. */
val KeepNothing = RetentionPolicy(enabled = false)
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
val Default = RetentionPolicy()
}
val keepsAnything: Boolean get() = enabled
}
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
val isEmpty: Boolean get() = removed.isEmpty()
}
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
init {
dir.mkdirs()
}
/**
* Writes one run and applies retention. Returns the index entry, or null when the policy
* keeps nothing at all — in which case nothing is written, rather than written and instantly
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
*/
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
if (!policy.keepsAnything) return null
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
writeAtomically(File(dir, meta.id + EXT), runJson)
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
purge(policy)
return meta
}
/** History, newest first. */
fun list(): List<ArchivedRun> =
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
.mapNotNull { f ->
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
}
.sortedByDescending { it.savedAtEpochMs }
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
fun delete(id: String): Boolean {
val s = safe(id)
val doc = File(dir, s + EXT).delete()
File(dir, s + META_EXT).delete()
return doc
}
fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) {
val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return
writeAtomically(
f,
json.encodeToString(
ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName),
),
)
}
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
/**
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
* should not be pre-empted by a size sweep deleting last week instead.
*/
fun purge(policy: RetentionPolicy): PurgeResult {
val removed = ArrayList<String>()
var freed = 0L
fun drop(r: ArchivedRun) {
if (delete(r.id)) {
removed.add(r.id)
freed += r.sizeBytes
}
}
var kept = list()
if (policy.maxAgeDays > 0) {
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
stale.forEach(::drop)
kept = fresh
}
if (policy.maxTotalBytes > 0) {
var total = kept.sumOf { it.sizeBytes }
// Oldest first until we are under the ceiling.
for (r in kept.reversed()) {
if (total <= policy.maxTotalBytes) break
drop(r)
total -= r.sizeBytes
}
kept = kept.filter { it.id !in removed }
}
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
}
return PurgeResult(removed, freed)
}
// ---- internals ---------------------------------------------------------------------
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
val run = doc["run"]?.jsonObject ?: return null
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
return ArchivedRun(
id = id,
savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
)
}
private fun writeAtomically(target: File, content: String) {
val tmp = File(target.parentFile, target.name + ".tmp")
tmp.writeText(content)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
private fun safe(id: String): String = buildString {
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
}.take(64)
private companion object {
const val EXT = ".json"
const val META_EXT = ".meta.json"
}
}
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.archive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RunArchiveTest {
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
private fun archive() = RunArchive(dir) { clock }
@AfterTest fun cleanup() { dir.deleteRecursively() }
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
}
@Test
fun savedRunsComeBackNewestFirst() {
val a = archive()
for (i in 1..3) {
a.save(doc("run-$i"))
clock += 60_000
}
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
}
@Test
fun theIndexSummarisesTheDocument() {
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
assertEquals("warn", meta.verdict)
assertEquals(4, meta.findingCount)
assertEquals("balanced", meta.anonymization)
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
assertFalse(meta.uploaded)
}
@Test
fun theDocumentComesBackByteForByte() {
val a = archive()
val original = doc("run-1")
a.save(original)
assertEquals(original, a.read("run-1"))
}
@Test
fun countLimitKeepsTheNewest() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
for (i in 1..7) {
a.save(doc("run-$i"), policy)
clock += 60_000
}
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
}
@Test
fun ageLimitDropsRunsPastTheWindow() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
a.save(doc("old"), policy)
clock += 30L * 24 * 60 * 60 * 1000 // a month later
a.save(doc("new"), policy)
assertEquals(listOf("new"), a.list().map { it.id })
}
@Test
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
val a = archive()
val one = doc("x", pad = 900).toByteArray().size.toLong()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
for (i in 1..5) {
a.save(doc("run-$i", pad = 900), policy)
clock += 60_000
}
val kept = a.list()
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
assertEquals("run-5", kept.first().id, "the newest run must always survive")
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
}
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
@Test
fun keepNothingWritesNothing() {
val a = archive()
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
assertTrue(a.list().isEmpty())
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
}
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
// one: turning every limit off should keep everything, not wipe the history.
@Test
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
assertEquals(5, a.list().size)
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
}
@Test
fun uploadStateIsRecorded() {
val a = archive()
a.save(doc("run-1"))
a.markUploaded("run-1", "fmr")
val meta = a.list().single()
assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
}
@Test
fun deleteRemovesBothFiles() {
val a = archive()
a.save(doc("run-1"))
assertTrue(a.delete("run-1"))
assertTrue(a.list().isEmpty())
assertNull(a.read("run-1"))
assertEquals(0, dir.listFiles()?.size ?: 0)
}
@Test
fun malformedInputIsRejectedRatherThanArchived() {
val a = archive()
assertNull(a.save("not json"))
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
assertTrue(a.list().isEmpty())
}
// Run ids come from a document that may have been produced elsewhere; they must not be able
// to write outside the archive directory.
@Test
fun runIdsCannotEscapeTheArchiveDirectory() {
val a = archive()
a.save(doc("../../evil"))
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
}
@Test
fun purgeReportsWhatItRemoved() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
assertEquals(3, result.removed.size)
assertTrue(result.freedBytes > 0)
assertEquals(2, a.list().size)
}
}
+1
View File
@@ -12,6 +12,7 @@ plugins {
dependencies {
implementation(project(":core-protocol"))
implementation(project(":core-measurement"))
implementation(project(":core-privacy"))
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
@@ -0,0 +1,344 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* The measurements only the far end can make: what the *downstream* path does to traffic the
* client never asked for packet-by-packet.
*
* A client alone can measure a round trip, and it can find the largest packet it can *send*. It
* cannot find the largest packet it can *receive*, or whether the network drops downstream
* packets independently of upstream ones — those need a server willing to push, which is why the
* protocol gates them behind an asymmetric grant (probe-protocol.md §3.4).
*
* Three separate facts come out, and keeping them separate is the point:
* - `mtu.pmtud_down` — the largest datagram that arrives *unfragmented*. This is the number
* that matters for anything setting DF, and it is only meaningful because the server sets DF.
* - `mtu.frag_delivery` — whether larger datagrams arrive once the network is allowed to
* fragment them. A path can be fine for one and broken for the other; conflating them is how
* you get "MTU is 4000" on a link that drops every DF packet over 1400.
* - `train.udp_downstream` — loss, reordering and arrival spacing in the download direction.
*/
class DownstreamMeasurement(private val ids: IdSource) {
private val json = Json { encodeDefaults = true; explicitNulls = true }
/** How long to wait for a granted burst after the server accepts the action. */
private val collectWindowMs = 4_000L
/**
* Runs all three against an already-primed session.
*
* [session] must already have sent at least one ECHO: the grant is bound to the source the
* server has actually observed, so an unprimed session gets a 409 rather than a grant. That
* is the anti-amplification rule doing its job, not an error to work around.
*/
fun run(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
sizes: List<Int> = DEFAULT_SIZES,
trainCount: Int = 100,
trainSizeBytes: Int = 300,
trainIntervalUs: Int = 3_000,
): Pair<List<Test>, List<Finding>> {
val tests = ArrayList<Test>()
val findings = ArrayList<Finding>()
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
val train = downTrain(
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs,
)
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
// A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud:
// it is the usual cause of "small requests work, large responses hang".
val pathMtu = df.largestDelivered
if (pathMtu != null && pathMtu > 0) {
val ipMtu = pathMtu + IP_UDP_OVERHEAD4
if (ipMtu < 1500) {
findings.add(
finding(
"mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id,
"Downstream path MTU is $ipMtu bytes, below 1500",
"The largest datagram that reached this device without fragmenting was " +
"$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " +
"IPv6-in-IPv4) commonly do this; it is only a fault when something " +
"on the path also blocks the ICMP messages that let senders discover it.",
),
)
}
// The dangerous combination: unfragmented large packets vanish AND fragments do too,
// so a sender that never gets told will retransmit into a black hole.
val fragLargest = frag.largestDelivered ?: 0
if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) {
findings.add(
finding(
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id,
"Datagrams above $pathMtu bytes are dropped downstream, fragmented or not",
"Nothing larger than $pathMtu bytes arrived, even when the network was " +
"free to fragment it. Traffic that relies on large responses will " +
"stall rather than fail cleanly.",
),
)
}
}
if (train.received == 0) {
findings.add(
finding(
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id,
"No server-initiated packets arrived",
"The server sent ${train.sent} packets toward this device and none arrived, " +
"while the round-trip echo worked. Something on the path forwards replies " +
"but drops traffic the device did not individually solicit.",
),
)
} else if (train.lossPct >= 5.0) {
findings.add(
finding(
"connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id,
"Downstream loss of ${round1(train.lossPct)}%",
"${train.sent - train.received} of ${train.sent} packets sent toward this " +
"device were lost. Downstream loss is invisible to a round-trip test, " +
"which reports only that *something* was lost somewhere.",
),
)
}
if (train.reordered > 0) {
findings.add(
finding(
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id,
"${train.reordered} downstream packet(s) arrived out of order",
"Packets arrived in a different order than they were sent. Usually per-packet " +
"load balancing across links; harmless for most traffic, not for all of it.",
),
)
}
return tests to findings
}
// ---- big_send ---------------------------------------------------------------------
private class SizeResult(val test: Test, val largestDelivered: Int?)
private fun bigSend(
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
sessionRef: String, sizes: List<Int>, df: Boolean,
): SizeResult {
val testId = ids.uuid()
val started = ids.monoNs()
val requested = sizes.joinToString(",")
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"big_send","df":$df,"sizes_bytes":[$requested]}""",
)
}
if (reply.isFailure) {
return SizeResult(
Test(
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "big_send refused"),
),
null,
)
}
// The server tells us which sizes it actually put on the wire. With DF it refuses
// anything above its own egress MTU, and treating those as "lost downstream" would
// blame the client's network for our own limit.
val accepted = parseIntArray(reply.getOrNull(), "sizes_bytes").ifEmpty { sizes }
val serverMaxDf = parseInt(reply.getOrNull(), "max_df_bytes")
val arrived = probe.collectGranted(collectWindowMs)
.filter { it.type == Wire.TYPE_BIG_SEND }
.map { it.sizeBytes }
.distinct()
.sorted()
val largest = arrived.maxOrNull()
val metrics = json.encodeToJsonElement(
BigSendMetrics(
requestedBytes = sizes,
sentBytes = accepted,
deliveredBytes = arrived,
largestDeliveredBytes = largest,
dontFragment = df,
serverMaxDfBytes = serverMaxDf,
// Only meaningful for the DF run; the IP-level MTU is the payload plus headers.
pathMtuBytes = if (df && largest != null) largest + IP_UDP_OVERHEAD4 else null,
),
) as JsonObject
val status = when {
arrived.isEmpty() -> TestStatus.FAILED
arrived.size < accepted.size -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return SizeResult(
Test(
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = status, metrics = metrics,
),
largest,
)
}
// ---- downtrain --------------------------------------------------------------------
private class TrainResult(
val test: Test, val sent: Int, val received: Int, val lossPct: Double, val reordered: Int,
)
private fun downTrain(
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int,
): TrainResult {
val testId = ids.uuid()
val started = ids.monoNs()
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""",
)
}
if (reply.isFailure) {
return TrainResult(
Test(
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef,
tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "downtrain refused"),
),
0, 0, 0.0, 0,
)
}
val sent = parseInt(reply.getOrNull(), "count") ?: count
val got = probe.collectGranted(collectWindowMs).filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val received = got.size
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
// Reordering: a packet whose sequence is below the highest already seen. Counting
// inversions rather than "not sorted" keeps one late packet from being reported as
// dozens of reorder events.
var highest = -1
var reordered = 0
for (p in got) {
if (p.seq < highest) reordered++ else highest = p.seq
}
// Columnar evidence per the schema: what arrived, when, and how big — so every metric
// above is recomputable by a reader who does not trust our arithmetic.
val evidence = TrainEvidence(
epochMonoNs = started,
seq = got.map { it.seq },
tTxNs = got.map { null },
tRxNs = got.map { it.tRxNs },
sizeBytes = got.map { it.sizeBytes },
).toEvidence()
val interArrival = got.zipWithNext { a, b -> (b.tRxNs - a.tRxNs) / 1_000_000.0 }
val metrics = json.encodeToJsonElement(
DownTrainMetrics(
sent = sent, received = received, lossPct = round1(lossPct),
reorderedPackets = reordered,
sizeBytes = sizeBytes,
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
sendIntervalUs = intervalUs,
),
) as JsonObject
val status = when {
received == 0 -> TestStatus.FAILED
received < sent -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return TrainResult(
Test(
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = status, evidence = evidence, metrics = metrics,
),
sent, received, lossPct, reordered,
)
}
// ---- helpers ----------------------------------------------------------------------
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
Finding(
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
)
/** Minimal scalar extraction from the action reply; the shape is small and server-owned. */
private fun parseInt(body: String?, key: String): Int? =
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
private fun parseIntArray(body: String?, key: String): List<Int> =
body?.let { b ->
Regex("\"$key\"\\s*:\\s*\\[([^\\]]*)\\]").find(b)?.groupValues?.get(1)
?.split(",")?.mapNotNull { it.trim().toIntOrNull() }
} ?: emptyList()
private companion object {
/** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */
const val IP_UDP_OVERHEAD4 = 28
/** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */
val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000)
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
/** Metrics for mtu.pmtud_down / mtu.frag_delivery. */
@Serializable
data class BigSendMetrics(
@SerialName("requested_bytes") val requestedBytes: List<Int>,
@SerialName("sent_bytes") val sentBytes: List<Int>,
@SerialName("delivered_bytes") val deliveredBytes: List<Int>,
@SerialName("largest_delivered_bytes") val largestDeliveredBytes: Int? = null,
@SerialName("dont_fragment") val dontFragment: Boolean,
/** The server's own DF ceiling; sizes above it were never sent and are not path evidence. */
@SerialName("server_max_df_bytes") val serverMaxDfBytes: Int? = null,
@SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null,
)
/** Metrics for train.udp_downstream. */
@Serializable
data class DownTrainMetrics(
val sent: Int,
val received: Int,
@SerialName("loss_pct") val lossPct: Double,
@SerialName("reordered_packets") val reorderedPackets: Int,
@SerialName("size_bytes") val sizeBytes: Int,
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
@SerialName("send_interval_us") val sendIntervalUs: Int,
)
@@ -36,6 +36,12 @@ class ServerMeasurement(
val udpPort: Int,
val echoCount: Int = 20,
val echoPaddingBytes: Int = 64,
/**
* Whether to ask the server to push traffic back (downstream MTU and downstream train).
* Costs a few hundred kB of download and needs a server that advertises the grants, so
* it is a flag rather than an assumption.
*/
val downstream: Boolean = true,
)
fun run(cfg: Config): MeasurementDocument {
@@ -57,11 +63,32 @@ class ServerMeasurement(
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
)
val (test, findings) = echoTrain(cfg, control, session, startMono)
val tests = ArrayList<Test>()
val allFindings = ArrayList<Finding>()
// One ProbeSession for the whole run. A second one would open a new socket and restart
// the sequence counter, which the server's anti-replay window correctly rejects — so the
// re-primed source is never recorded and every granted send goes to the old, closed port.
// Session identity lives on the server; the socket must live as long as it does.
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
val (test, findings) = echoTrain(cfg, ps, startMono)
tests.add(test)
allFindings.addAll(findings)
// Downstream needs a session the server has already seen traffic from — the echo
// train just provided that — and a server that advertises the grants. Skipped
// quietly against an older server rather than reported as a failure of the network.
if (cfg.downstream && profile.supports("downtrain") && profile.supports("big-send")) {
val (dsTests, dsFindings) = DownstreamMeasurement(ids)
.run(cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1")
tests.addAll(dsTests)
allFindings.addAll(dsFindings)
}
}
control.deleteSession(cfg.credential, session.sessionId)
val summary = Verdicts.derive(listOf(test), findings)
val summary = Verdicts.derive(tests, allFindings)
return MeasurementDocument(
run = Run(
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
@@ -70,14 +97,14 @@ class ServerMeasurement(
tiers = Tiers(app = true),
),
serverSessions = listOf(serverSession),
tests = listOf(test),
findings = findings,
tests = tests,
findings = allFindings,
summary = summary,
)
}
private fun echoTrain(
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
cfg: Config, ps: ProbeSession, startMono: Long,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val seqs = ArrayList<Int>()
@@ -87,20 +114,18 @@ class ServerMeasurement(
val rtts = ArrayList<Double>()
val observedPorts = LinkedHashSet<Int>()
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes)
seqs.add(i)
tTx.add(txMono)
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
if (r != null) {
tRx.add(ids.monoNs() - startMono)
rtts.add(r.rttMs)
r.observation?.observedPort?.let { observedPorts.add(it) }
} else {
tRx.add(null)
}
for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes)
seqs.add(i)
tTx.add(txMono)
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
if (r != null) {
tRx.add(ids.monoNs() - startMono)
rtts.add(r.rttMs)
r.observation?.observedPort?.let { observedPorts.add(it) }
} else {
tRx.add(null)
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.Compat
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.VersionRefused
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Checks the version gate against a LIVE server — the half that unit tests cannot reach, because
* the whole point is that two independently-built artifacts agree. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveCompatTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private fun clientAs(version: String) = ControlClient(url!!, setOf(pin!!), version)
@Test
fun theServerAdvertisesAndEnforcesItsWindow() {
if (url == null || pin == null || cred == null) {
println("LiveCompatTest skipped (no ECHOLOT_LIVE_* env)"); return
}
// The profile must state the window — without it the app cannot pre-empt a refusal.
val profile = clientAs("0.2.0").profile(cred)
println("server ${profile.serverVersion} protocol=${profile.compat.protocolVersion} " +
"accepts app [${profile.compat.appMin}, ${profile.compat.appMax})")
assertTrue(profile.compat.protocolVersion.isNotBlank(), "profile omits protocol_version")
assertTrue(profile.compat.appMin.isNotBlank(), "profile omits app_min")
// This build must be inside it, or every other live test here is meaningless.
val verdict = Compat.check(profile, "0.2.0")
assertEquals(Compat.Verdict.OK, verdict.verdict, verdict.message ?: "")
// The profile stays reachable for a version the server would otherwise refuse: that is
// how a refused client discovers what it needs.
val ancient = clientAs("0.1.0")
val stillReadable = ancient.profile(cred)
assertEquals(profile.serverVersion, stillReadable.serverVersion,
"the profile endpoint must never be gated on app version")
// And a gated endpoint refuses it, with a message naming the window.
try {
ancient.createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an out-of-window app")
} catch (e: VersionRefused) {
val msg = assertNotNull(e.message)
println("refused as expected: $msg")
assertTrue(msg.contains("0.1.0"), "refusal should name the offending version: $msg")
assertTrue(msg.contains(profile.compat.appMin), "refusal should name the window: $msg")
}
// Too new is refused the same way — the window is a range, not a floor.
try {
clientAs("99.0.0").createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an app above its window")
} catch (e: VersionRefused) {
println("too-new refused as expected: ${e.message}")
}
}
}
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not
* just that packets moved: the tests must carry recomputable metrics and land on the right test
* types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*.
*/
class LiveDownstreamTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@JTest
fun producesDownstreamTestsAndFindings() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (tests, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo() // prime: the grant binds to the source the server has actually observed
DownstreamMeasurement(SystemIdSource())
.run(cred, session.sessionId, control, ps, sessionRef = "sess-1")
}
control.deleteSession(cred, session.sessionId)
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train")
val byType = tests.associateBy { it.type }
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL,
"DF probe did not deliver anything: ${pmtud.status}")
val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull()
assertNotNull(pathMtu, "pmtud_down must report a path MTU")
assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu")
println("downstream path MTU = $pathMtu bytes")
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
assertTrue(received > 0, "no downstream train packets arrived")
println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " +
"reordered=${train.metrics?.get("reordered_packets")}")
}
}
@@ -54,19 +54,35 @@ class LiveGrantedTest {
"sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send: which downstream sizes survive? ---
// --- big_send with DF: the largest size that arrives is the downstream path MTU ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val bsResp = control.action(
val dfResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","sizes_bytes":${sizes}}""",
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
)
println("big_send accepted: ${bsResp.take(160)}")
val big = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }
val arrived = big.map { it.sizeBytes }.sorted()
println("big_send arrived sizes: $arrived (requested $sizes)")
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
println("big_send(df) accepted: ${dfResp.take(200)}")
val dfArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(df) arrived: $dfArrived")
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
val pathMtu = dfArrived.max()
// --- and without DF, to see whether fragments get through above that ---
val fragResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
)
println("big_send(frag) accepted: ${fragResp.take(200)}")
val fragArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(frag) arrived: $fragArrived")
// The distinction the DF flag exists for: fragmented delivery may exceed the
// unfragmented path MTU, and reporting the former as the latter would be a lie.
println("downstream path MTU (payload bytes) = $pathMtu; " +
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
"fragmented delivery should reach at least as far as unfragmented")
}
control.deleteSession(cred, session.sessionId)
}
@@ -47,8 +47,11 @@ class LiveMeasurementTest {
assertEquals(1, doc.serverSessions.size)
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
val test = doc.tests.single()
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
// A full run is the echo train plus the three downstream tests; assert on the one this
// test is about rather than on the count, so adding a measurement is not a test edit.
for (t in doc.tests) println(" ${t.type}${t.status}")
for (f in doc.findings) println(" finding ${f.code} [${f.severity}] ${f.title}")
val test = doc.tests.first { it.type == TestType.TRAIN_UDP_UPDOWN }
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
"expected replies from live server, got ${test.status}")
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
*
* The point is not that the HTTP works — it is that what comes *back off the server* has been
* stripped. Uploading and then re-reading the stored document is the only check that proves the
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveUploadTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val json = Json { prettyPrint = false }
private fun sampleRun(id: String) = """
{
"schema": "echolot/measurement",
"run": {
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
"notes": "kitchen table",
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
}],
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
"summary": {"verdict": "warn"}
}
""".trimIndent()
@Test
fun uploadRoundTrip() {
if (url == null || pin == null || cred == null) {
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
val policy = profile.uploads
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
val redacted = json.encodeToString(
JsonObject.serializer(),
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
)
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
if (!policy.accepted) {
// A server configured to refuse must refuse — that is the behaviour worth asserting.
try {
control.uploadRun(cred, redacted)
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
} catch (e: UploadRefused) {
println("upload correctly refused: ${e.message?.take(140)}")
return
}
}
val created = control.uploadRun(cred, redacted)
println("stored: ${created.take(200)}")
val listed = control.listRuns(cred)
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
val fetched = control.getRun(cred, runId)
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
println("round trip verified: identifiers stripped, measurements intact")
control.deleteRun(cred, runId)
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
println("deleted")
}
}
@@ -1,100 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package measurement models one measurement run (measurement-schema.md) — the archived,
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class MeasurementDocument(
val schema: String = "echolot/measurement",
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
val run: Run,
val networks: List<Network> = emptyList(),
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
val tests: List<Test> = emptyList(),
val findings: List<Finding> = emptyList(),
val summary: Summary? = null,
)
@Serializable
data class Run(
val id: String, // UUIDv7
val trigger: Trigger,
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
@SerialName("ended_at") val endedAt: String? = null,
val clock: Clock,
val app: AppInfo,
val device: DeviceInfo,
val tiers: Tiers,
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
val notes: String? = null,
)
@Serializable
enum class Trigger {
@SerialName("manual") MANUAL,
@SerialName("scheduled") SCHEDULED,
@SerialName("monitor") MONITOR,
@SerialName("peer") PEER,
}
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
* all math uses *_mono_ns relative to that monotonic origin. */
@Serializable
data class Clock(
@SerialName("mono_origin_wall") val monoOriginWall: String,
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
)
@Serializable
data class AppInfo(
val version: String,
val build: Int,
val git: String? = null,
val flavor: String? = null,
)
@Serializable
data class DeviceInfo(
val manufacturer: String,
val model: String,
@SerialName("android_sdk") val androidSdk: Int,
@SerialName("android_release") val androidRelease: String,
@SerialName("security_patch") val securityPatch: String? = null,
)
/** What each tier was *available*; each test records what it *used*. */
@Serializable
data class Tiers(
val app: Boolean = true,
val shizuku: Boolean = false,
val root: Boolean = false,
)
@Serializable
data class ServerSession(
val id: String,
@SerialName("profile_id") val profileId: String? = null,
@SerialName("profile_name") val profileName: String? = null,
@SerialName("control_url") val controlUrl: String,
@SerialName("server_version") val serverVersion: String? = null,
val capabilities: List<String> = emptyList(),
@SerialName("session_id") val sessionId: String,
val target: SessionTarget,
)
@Serializable
data class SessionTarget(
val ip4: String? = null,
val ip6: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
)
@@ -1,85 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
* means the columnar/traceroute/resolver contracts live in one place.
*/
@PublishedApi
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
inline fun <reified T> T.toEvidence(): JsonObject =
evidenceJson.encodeToJsonElement(this) as JsonObject
/**
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
* columns use the server session epoch; only differences within one clock are meaningful unless a
* time.server_offset test maps them.
*/
@Serializable
data class TrainEvidence(
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
val seq: List<Int>,
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
@SerialName("size_bytes") val sizeBytes: List<Int>,
@SerialName("dscp_sent") val dscpSent: Int? = null,
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
@SerialName("ecn_sent") val ecnSent: Int? = null,
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
)
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
@Serializable
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
@Serializable
data class Flow(
@SerialName("src_port") val srcPort: Int,
@SerialName("dst_port") val dstPort: Int,
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
)
@Serializable
data class Hop(val ttl: Int, val probes: List<HopProbe>)
@Serializable
data class HopProbe(
@SerialName("reply_from") val replyFrom: String? = null,
@SerialName("rtt_ns") val rttNs: Long? = null,
val icmp: String? = null,
@SerialName("reply_ttl") val replyTtl: Int? = null,
)
/** Resolver under test (§6.4); every dns.* test carries this in params. */
@Serializable
data class ResolverSpec(
val source: ResolverSource,
val address: String? = null,
val port: Int = 53,
val transport: String, // do53-udp | do53-tcp | dot | doh
@SerialName("doh_url") val dohUrl: String? = null,
)
@Serializable
enum class ResolverSource {
@SerialName("system") SYSTEM,
@SerialName("manual") MANUAL,
@SerialName("server-recursive") SERVER_RECURSIVE,
}
@@ -1,68 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
@Serializable
data class Finding(
val id: String, // UUIDv7
val code: String, // stable registry (findings-registry.md), lint-rule style
val category: Category,
val severity: Severity,
val confidence: Confidence,
@SerialName("network_ref") val networkRef: String? = null,
val title: String,
val description: String,
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
val recommendation: String? = null,
) {
init {
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
}
}
@Serializable
data class EvidenceRef(val test: String, val pointer: String? = null)
/** Fixed §7.2 categories; each maps to one traffic light. */
@Serializable
enum class Category {
@SerialName("connectivity") CONNECTIVITY,
@SerialName("dns") DNS,
@SerialName("nat") NAT,
@SerialName("mtu") MTU,
@SerialName("ipv6") IPV6,
@SerialName("security") SECURITY,
@SerialName("performance") PERFORMANCE,
@SerialName("local") LOCAL,
@SerialName("wifi") WIFI,
}
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
@Serializable
enum class Severity(val rank: Int) {
@SerialName("critical") CRITICAL(4),
@SerialName("high") HIGH(3),
@SerialName("medium") MEDIUM(2),
@SerialName("low") LOW(1),
@SerialName("info") INFO(0);
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
fun toLight(): Verdict = when (this) {
CRITICAL, HIGH -> Verdict.RED
MEDIUM, LOW -> Verdict.YELLOW
INFO -> Verdict.GREEN
}
}
@Serializable
enum class Confidence {
@SerialName("high") HIGH,
@SerialName("medium") MEDIUM,
@SerialName("low") LOW,
}
@@ -1,113 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
@Serializable
data class Network(
val id: String,
val transport: Transport,
@SerialName("interface") val iface: String? = null,
val link: Link,
val wifi: Wifi? = null,
val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(),
)
@Serializable
enum class Transport {
@SerialName("wifi") WIFI,
@SerialName("cellular") CELLULAR,
@SerialName("ethernet") ETHERNET,
@SerialName("vpn") VPN,
@SerialName("other") OTHER,
}
@Serializable
data class Link(
val mtu: Int? = null,
val addresses: List<Address> = emptyList(),
val routes: List<Route> = emptyList(),
val dns: DnsConfig? = null,
val dhcp: Dhcp? = null,
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
)
@Serializable
data class Address(
val addr: String, // ip4 | ip6 (logical type, §8)
@SerialName("prefix_len") val prefixLen: Int,
val scope: String? = null,
val flags: List<String> = emptyList(),
@SerialName("valid_lft_s") val validLftS: Long? = null,
@SerialName("pref_lft_s") val prefLftS: Long? = null,
)
@Serializable
data class Route(
val dst: String,
val gateway: String? = null,
val iface: String? = null,
val proto: RouteProto? = null, // shizuku tier; null = not observed
@SerialName("expires_s") val expiresS: Long? = null,
)
@Serializable
enum class RouteProto {
@SerialName("dhcp") DHCP,
@SerialName("ra") RA,
@SerialName("static") STATIC,
@SerialName("unknown") UNKNOWN,
}
@Serializable
data class DnsConfig(
val servers: List<String> = emptyList(),
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
)
@Serializable
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
@Serializable
data class CaptivePortal(
val detected: Boolean = false,
@SerialName("api_url") val apiUrl: String? = null,
@SerialName("venue_url") val venueUrl: String? = null,
)
@Serializable
data class Wifi(
val ssid: String? = null, // ssid (logical type)
val bssid: String? = null, // bssid (logical type)
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
val standard: String? = null,
val security: String? = null,
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
)
@Serializable
data class Cellular(
val rat: String? = null,
val operator: String? = null,
val band: String? = null,
)
@Serializable
data class NetworkChange(
@SerialName("at_mono_ns") val atMonoNs: Long,
val kind: String, // lost | gained | link_changed
val detail: JsonObject? = null,
)
@@ -1,90 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class Verdict {
@SerialName("green") GREEN,
@SerialName("yellow") YELLOW,
@SerialName("red") RED,
@SerialName("inconclusive") INCONCLUSIVE,
}
@Serializable
data class Summary(
val overall: Verdict,
val categories: Map<String, CategorySummary>,
)
@Serializable
data class CategorySummary(
val verdict: Verdict,
@SerialName("worst_finding") val worstFinding: String? = null,
@SerialName("tests_run") val testsRun: Int,
@SerialName("tests_failed") val testsFailed: Int,
)
/**
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
*
* - A category's verdict = the light of its worst-severity finding
* (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.
*
* The mapping test-type → category comes from [TestType.category]. Only categories that have
* findings or tests appear in the summary.
*/
object Verdicts {
private fun isInconclusiveTest(s: TestStatus) =
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
val testsByCat = tests.groupBy { TestType.category(it.type) }
val findingsByCat = findings.groupBy { it.category }
val categories = (testsByCat.keys + findingsByCat.keys)
val perCat = LinkedHashMap<String, CategorySummary>()
for (cat in Category.entries) {
if (cat !in categories) continue
val catTests = testsByCat[cat].orEmpty()
val catFindings = findingsByCat[cat].orEmpty()
val failed = catTests.count { isInconclusiveTest(it.status) }
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
val worst = catFindings.maxByOrNull { it.severity.rank }
val verdict = when {
inconclusive -> Verdict.INCONCLUSIVE
worst == null -> Verdict.GREEN
else -> worst.severity.toLight()
}
perCat[serialName(cat)] = CategorySummary(
verdict = verdict,
worstFinding = worst?.id,
testsRun = catTests.size,
testsFailed = failed,
)
}
val overall = deriveOverall(perCat.values)
return Summary(overall = overall, categories = perCat)
}
/** Overall = worst light; inconclusive only if every category is inconclusive. */
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
// Non-inconclusive categories decide the overall light.
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
}
private fun serialName(cat: Category): String = cat.name.lowercase()
}
@@ -1,149 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
* per-test-type JSON, so they are carried as JsonObject. */
@Serializable
data class Test(
val id: String, // UUIDv7
val type: String, // TestType registry (§6.1)
@SerialName("network_ref") val networkRef: String? = null,
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
val tier: Tier,
@SerialName("started_mono_ns") val startedMonoNs: Long,
@SerialName("ended_mono_ns") val endedMonoNs: Long,
val status: TestStatus,
val error: TestError? = null,
val params: JsonObject? = null,
val evidence: JsonObject? = null,
val metrics: JsonObject? = null,
)
@Serializable
enum class Tier {
@SerialName("app") APP,
@SerialName("shizuku") SHIZUKU,
@SerialName("root") ROOT,
}
@Serializable
enum class TestStatus {
@SerialName("ok") OK,
@SerialName("failed") FAILED,
@SerialName("unsupported") UNSUPPORTED,
@SerialName("skipped") SKIPPED,
@SerialName("partial") PARTIAL,
}
@Serializable
data class TestError(val code: String, val detail: String? = null)
/**
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
* the fixed §7.2 categories for verdict rollup.
*/
object TestType {
// link
const val LINK_SNAPSHOT = "link.snapshot"
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
const val LINK_IP_MONITOR = "link.ip_monitor"
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
const val LINK_RA_SOURCE = "link.ra_source"
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
// icmp
const val ICMP_PING4 = "icmp.ping4"
const val ICMP_PING6 = "icmp.ping6"
// trace
const val TRACEROUTE_UDP4 = "traceroute.udp4"
const val TRACEROUTE_UDP6 = "traceroute.udp6"
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
// train
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
// mtu
const val MTU_PMTUD_UP = "mtu.pmtud_up"
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
const val MTU_BLACKHOLE = "mtu.blackhole"
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
// nat
const val NAT_STUN_5780 = "nat.stun_5780"
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
const val NAT_HAIRPIN = "nat.hairpin"
const val NAT_CONNECT_BACK = "nat.connect_back"
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
// dns
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
const val DNS_CANARY = "dns.canary"
const val DNS_INTERCEPTION = "dns.interception"
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
const val DNS_DNSSEC = "dns.dnssec"
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
const val DNS_REBIND_FILTER = "dns.rebind_filter"
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
const val DNS_DNS64 = "dns.dns64"
const val DNS_COMPARE = "dns.compare"
// sec
const val SEC_TLS_REFERENCE = "sec.tls_reference"
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
const val SEC_HTTP_ECHO = "sec.http_echo"
const val SEC_SNI_FILTER = "sec.sni_filter"
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
const val SEC_ARP_WATCH = "sec.arp_watch"
// port
const val PORT_REACH_SWEEP = "port.reach_sweep"
const val PORT_UDP_USABILITY = "port.udp_usability"
// perf
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
const val PERF_RRC_LATENCY = "perf.rrc_latency"
// v6
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
const val V6_BROKENNESS = "v6.brokenness"
const val V6_NAT64_CLAT = "v6.nat64_clat"
// wifi
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
const val WIFI_ROAM_LOG = "wifi.roam_log"
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
// local
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
const val PEER_REACHABILITY = "peer.reachability"
const val PEER_ISOLATION = "peer.isolation"
const val PEER_MULTICAST = "peer.multicast"
const val PEER_LAN_TRAIN = "peer.lan_train"
const val PEER_LEASE_DIFF = "peer.lease_diff"
// time
const val TIME_SERVER_OFFSET = "time.server_offset"
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
fun category(type: String): Category = when (type.substringBefore('.')) {
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
"dns" -> Category.DNS
"nat" -> Category.NAT
"mtu" -> Category.MTU
"v6" -> Category.IPV6
"sec" -> Category.SECURITY
"perf" -> Category.PERFORMANCE
"local", "peer" -> Category.LOCAL
"wifi" -> Category.WIFI
else -> Category.CONNECTIVITY
}
}
@@ -1,71 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.json.Json
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SerializationTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@JTest
fun documentRoundTrips() {
val doc = MeasurementDocument(
run = Run(
id = "0198c5f2-0000-7000-8000-000000000000",
trigger = Trigger.MANUAL,
startedAt = "2026-07-31T14:03:21.114Z",
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
app = AppInfo(version = "0.1.0", build = 1),
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
tiers = Tiers(app = true, shizuku = true),
),
networks = listOf(
Network(
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
wifi = Wifi(ssid = "example", rssiDbm = -54),
),
),
tests = listOf(
Test(
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
evidence = TrainEvidence(
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
).toEvidence(),
),
),
)
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
assertEquals(doc.run.id, decoded.run.id)
assertEquals(Transport.WIFI, decoded.networks[0].transport)
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
// snake_case field names on the wire
assertTrue(encoded.contains("\"schema_version\""))
assertTrue(encoded.contains("\"mono_origin_wall\""))
assertTrue(encoded.contains("\"t_tx_ns\""))
// null preserved at train index 1
assertTrue(encoded.contains("[16500000,null]"))
}
@JTest
fun findingRequiresEvidence() {
try {
Finding(
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
)
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
} catch (e: IllegalArgumentException) {
// expected — a finding with no evidence is invalid (§7.1)
}
}
}
@@ -1,109 +0,0 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
class VerdictsTest {
private fun test(type: String, status: TestStatus, id: String = type): Test =
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
Finding(
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
)
@JTest
fun categoryLightFromWorstSeverity() {
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
val findings = listOf(
finding(Category.DNS, Severity.LOW),
finding(Category.DNS, Severity.HIGH), // worst → red
finding(Category.DNS, Severity.INFO),
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun noFindingsIsGreen() {
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall)
}
@JTest
fun mediumAndLowAreYellow() {
val s = Verdicts.derive(
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
)
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
}
@JTest
fun majorityFailedIsInconclusive() {
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(2, s.categories["dns"]!!.testsFailed)
assertEquals(3, s.categories["dns"]!!.testsRun)
}
@JTest
fun exactlyHalfFailedIsNotInconclusive() {
// 1 of 2 failed → not > 50% → the finding decides.
val tests = listOf(
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
}
@JTest
fun overallIsWorstCategory() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
)
val findings = listOf(
finding(Category.DNS, Severity.MEDIUM), // yellow
finding(Category.MTU, Severity.CRITICAL), // red
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun overallInconclusiveOnlyWhenAllAre() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
)
val s = Verdicts.derive(tests, emptyList())
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
}
@JTest
fun categoryMappingCoversFamilies() {
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
}
}
@@ -69,6 +69,8 @@ object TestType {
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
// train
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
/** Server-to-client train under a §3.4 grant: the direction a round trip cannot separate. */
const val TRAIN_UDP_DOWNSTREAM = "train.udp_downstream"
// mtu
const val MTU_PMTUD_UP = "mtu.pmtud_up"
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
// dependency-free beyond JSON: it must be trivially auditable, because a bug
// here leaks a user's network onto someone else's server.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8.
//
// The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone.
//
// Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */
FULL("full"),
/**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast.
*/
BALANCED("balanced"),
/**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work.
*/
STRICT("strict");
companion object {
fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
}
}
/**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own — the app makes that an explicit choice, not a default.
*/
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
}
}
/**
* Transforms a measurement document to [level].
*
* Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise — see [Classification].
*/
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out)
}
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject {
put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run")
}
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
}
// ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) {
val childPath = path + k
when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath))
}
}
}
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
else v
}
private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value)
LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
}
// ---- per-type transforms -------------------------------------------------------------
/**
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
* MikroTik" does not.
*/
private fun macPreservingOui(value: String): String {
val sep = if (value.contains('-')) '-' else ':'
val parts = value.split(sep)
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
val nic = pseudo("mac", value) { it }
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
.joinToString(sep.toString())
.lowercase(Locale.ROOT)
}
/**
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
* CGNAT addresses say something about the topology and nothing about the person, and a run
* where 192.168.1.1 became a random public address would be actively misleading to read.
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/
private fun ip4(value: String): String {
val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() }
val reserved = n[0] == 10 ||
(n[0] == 172 && n[1] in 16..31) ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) ||
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value
val h = pseudo("ip4", value) { it }
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
}
/**
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
* is a device fingerprint, especially with EUI-64.
*/
private fun ip6(value: String): String {
val v = value.lowercase(Locale.ROOT)
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v
val h = pseudo("ip6", value) { it }
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
}
/**
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
* or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
*/
private fun fqdn(value: String): String {
if (value.isEmpty()) return value
val trailing = value.endsWith(".")
val labels = value.trimEnd('.').split(".")
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
}
// ---- STRICT ---------------------------------------------------------------------------
/**
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
* (it interpolates real names), networks go entirely.
*/
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
/** The logical types of measurement-schema.md §8. */
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
/**
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
*
* This is a table on purpose. The alternative — annotating the Kotlin models and reflecting over
* them — spreads the answer across every module and makes "what exactly gets uploaded?" a
* question you answer by reading the whole app. Here it is one file a reviewer can check against
* the spec in a sitting, and a new field that nobody classified stays visible in the output
* rather than being silently mangled.
*
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
* an SSID wrongly kept is a leak nobody notices.
*/
object Classification {
private val byKey: Map<String, LogicalType> = buildMap {
listOf(
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
).forEach { put(it, LogicalType.IP4) }
listOf(
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
"link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
}
/**
* Whole subtrees that BALANCED removes rather than pseudonymizes.
*
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
* case: they describe other people's devices, they are a household fingerprint even with the
* names hashed, and no metric depends on them. Dropping beats mangling.
*/
private val droppedPaths: List<List<String>> = listOf(
listOf("networks", "neighbors"),
listOf("networks", "arp"),
listOf("networks", "wifi", "scan_results"),
listOf("run", "device", "security_patch"),
)
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
byKey[key]?.let { return it }
// Inside a discovery/neighbour container every string is someone's device name until
// proven otherwise, so classify unknown strings there as free text rather than passing
// them through.
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
return null
}
fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These tests are the audit of the anonymizer: each one states a property someone's privacy
* depends on, so a regression here fails loudly rather than quietly leaking.
*/
class AnonymizerTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
private fun sample(): JsonObject = json.parseToJsonElement(
"""
{
"schema": "echolot/measurement",
"run": {
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
}],
"tests": [{
"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
}],
"findings": [{
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
}],
"summary": {"verdict": "warn"}
}
""".trimIndent(),
).jsonObject
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
private fun flat(e: JsonElement): String = e.toString()
@Test
fun fullLeavesTheDocumentAloneButRecordsThat() {
val out = anon(PrivacyLevel.FULL)
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
}
@Test
fun balancedRemovesTheSsidAndTheNotes() {
val text = flat(anon(PrivacyLevel.BALANCED))
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
}
@Test
fun balancedDropsNeighbourInventoriesEntirely() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
}
@Test
fun balancedKeepsTheVendorHalfOfAMac() {
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
}
@Test
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
"RFC1918 says nothing about the user and everything about the topology")
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
"the /16 should survive for ISP-level context")
}
@Test
fun linkLocalIsKeptButGlobalV6IsNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
}
@Test
fun metricsAndVerdictsAreNeverTouched() {
for (level in PrivacyLevel.entries) {
val out = anon(level)
val t = out["tests"]!!.jsonArray[0].jsonObject
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
"$level changed a metric")
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
}
}
@Test
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
for (level in PrivacyLevel.entries) {
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
}
}
@Test
fun strictDropsEvidenceAndProse() {
val out = anon(PrivacyLevel.STRICT)
assertNull(out["networks"], "STRICT should not describe the network at all")
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
}
@Test
fun pseudonymsAreConsistentWithinADocument() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
).jsonObject
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
assertNotEquals(a, c, "different SSIDs must not collide")
}
@Test
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
assertNotEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
"a per-run salt must not produce a cross-run tracking identifier",
)
}
@Test
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val secret = ByteArray(32) { 7 }
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
assertEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
)
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
}
@Test
fun theDeclaredLevelMatchesWhatWasApplied() {
for (level in PrivacyLevel.entries) {
assertEquals(
level.wire,
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
)
}
}
@Test
fun serverMinimumWins() {
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
}
}
@@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
/**
* Version compatibility, the client side of the same question the server asks about us.
*
* Versions are SemVer, but what is really being checked is whether the peer speaks a wire protocol
* and schema this build understands; the version is a proxy, and it only works because the
* breaking axis is bumped when the contract changes. Bounds therefore sit at breaking boundaries
* rather than at individual releases — a server patch release must never make the app refuse to
* talk to it.
*
* Mirrors `server/internal/compat`. Two implementations of one rule is a duplication worth
* accepting: each side must be able to state and enforce its own limits without asking the other,
* which is the entire point of a compatibility check.
*/
data class SemVer(
val major: Int,
val minor: Int,
val patch: Int,
val pre: String = "",
) : Comparable<SemVer> {
override fun compareTo(other: SemVer): Int {
(major - other.major).let { if (it != 0) return it.coerceIn(-1, 1) }
(minor - other.minor).let { if (it != 0) return it.coerceIn(-1, 1) }
(patch - other.patch).let { if (it != 0) return it.coerceIn(-1, 1) }
// A pre-release sorts below the same version without one (SemVer §11).
return when {
pre == other.pre -> 0
pre.isEmpty() -> 1
other.pre.isEmpty() -> -1
else -> pre.compareTo(other.pre).coerceIn(-1, 1)
}
}
override fun toString(): String = "$major.$minor.$patch" + if (pre.isEmpty()) "" else "-$pre"
/**
* The first version that may break compatibility with this one. Below 1.0.0 the minor is the
* breaking axis (SemVer §4), so 0.4.2's next break is 0.5.0 — treating it as 1.0.0 would let
* this build accept a peer it cannot actually talk to.
*/
fun nextBreaking(): SemVer =
if (major == 0) SemVer(0, minor + 1, 0) else SemVer(major + 1, 0, 0)
companion object {
/** Accepts "1.2.3", "v1.2.3" and namespaced tags like "server-v1.2.3". Null if unusable. */
fun parse(raw: String?): SemVer? {
var s = raw?.trim().orEmpty()
if (s.isEmpty()) return null
// Strip a tag prefix ending in "v", guarded on the prefix having no digits so a
// pre-release identifier containing a "v" is left alone.
val v = s.lastIndexOf('v')
if (v >= 0 && v + 1 < s.length && s[v + 1].isDigit() && s.take(v).none { it.isDigit() }) {
s = s.substring(v + 1)
}
s = s.substringBefore('+')
val pre = s.substringAfter('-', "")
val core = s.substringBefore('-')
val parts = core.split(".")
if (parts.size != 3) return null
val nums = parts.map { it.toIntOrNull() ?: return null }
if (nums.any { it < 0 }) return null
return SemVer(nums[0], nums[1], nums[2], pre)
}
}
}
/** [min, max): minimum inclusive, maximum exclusive. A null max is unbounded. */
data class VersionRange(val min: SemVer, val max: SemVer? = null) {
operator fun contains(v: SemVer): Boolean = v >= min && (max == null || v < max)
override fun toString(): String = ">= $min" + (max?.let { ", < $it" } ?: "")
companion object {
fun of(min: String, max: String?): VersionRange {
val lo = requireNotNull(SemVer.parse(min)) { "bad minimum version: $min" }
val hi = max?.takeIf { it.isNotBlank() }?.let {
requireNotNull(SemVer.parse(it)) { "bad maximum version: $it" }
}
require(hi == null || hi > lo) { "maximum $max is not above minimum $min" }
return VersionRange(lo, hi)
}
}
}
/**
* What this build of the app requires of a server, and what it tells servers about itself.
*
* Two separate questions, deliberately not conflated:
*
* - **Protocol version** — can these builds talk at all? This is the correctness axis, and its
* breaking boundary is enforced strictly.
* - **Release-version window** — should they, per policy? A coarse safety net over the peer's
* SemVer, with bounds at breaking boundaries so a patch release never strands anyone. Both
* sides publish their own, and the operator can tighten the server's.
*
* `MIN_SERVER` is 0.4.2 for a concrete reason, not caution: below it a multi-homed server sent
* granted traffic from an address the session never used, so every downstream packet was dropped
* in transit and reported as 100 % downstream loss. A confidently wrong measurement is worse than
* a refused one, so talking to those builds is not something to allow "just in case".
*/
object Compat {
/** Header the app sets on every control-plane request. */
const val APP_VERSION_HEADER = "X-Echolot-App-Version"
/** The wire contract (probe-protocol.md) this build implements. */
const val PROTOCOL_VERSION = "1.0.0"
const val MIN_SERVER = "0.4.2"
/** Exclusive. The next breaking series is refused until this app is taught about it. */
const val MAX_SERVER = "1.0.0"
val serverRange: VersionRange = VersionRange.of(MIN_SERVER, MAX_SERVER)
enum class Verdict { OK, PROTOCOL_MISMATCH, SERVER_TOO_OLD, SERVER_TOO_NEW, APP_REFUSED, UNKNOWN }
/**
* The result of checking a server, with a message written for the person holding the phone.
* [usable] is what callers branch on; [message] is what they show.
*/
data class Result(val verdict: Verdict, val message: String?) {
val usable: Boolean get() = verdict == Verdict.OK || verdict == Verdict.UNKNOWN
}
/**
* Checks a profile both ways: is the server within our range, and are we within the server's.
*
* Asking both is the point of advertising the window in the profile. Discovering that the
* server will refuse us only when a measurement fails halfway through is a much worse
* experience than being told before the run starts.
*/
fun check(profile: Profile, appVersion: String): Result {
// The protocol version is the axis that decides whether these two builds *can* talk;
// the release-version window below is the operator's policy about whether they *may*.
// Checking the real thing first means a mismatch is reported as what it is.
val ours = SemVer.parse(PROTOCOL_VERSION)!!
val theirs = SemVer.parse(profile.compat.protocolVersion)
if (theirs != null && theirs >= ours.nextBreaking()) {
return Result(
Verdict.PROTOCOL_MISMATCH,
"This server speaks probe protocol ${profile.compat.protocolVersion}; this app " +
"speaks $PROTOCOL_VERSION and does not understand that revision. Update the app.",
)
}
if (theirs != null && ours >= theirs.nextBreaking()) {
return Result(
Verdict.PROTOCOL_MISMATCH,
"This server speaks probe protocol ${profile.compat.protocolVersion}, which this " +
"app ($PROTOCOL_VERSION) has moved past. Update the server.",
)
}
val server = SemVer.parse(profile.serverVersion)
?: return Result(
Verdict.UNKNOWN,
"Server did not report a usable version (\"${profile.serverVersion}\") — " +
"continuing without a compatibility check.",
)
if (server < serverRange.min) {
return Result(
Verdict.SERVER_TOO_OLD,
"This server runs $server; Echolot needs $serverRange. " +
"Measurements against older servers can be wrong rather than merely missing, " +
"so update the server.",
)
}
if (serverRange.max != null && server >= serverRange.max) {
return Result(
Verdict.SERVER_TOO_NEW,
"This server runs $server, which is newer than this app understands " +
"($serverRange). Update the app.",
)
}
// And the server's own view of us.
val app = SemVer.parse(appVersion)
val serverWantsMin = SemVer.parse(profile.compat.appMin)
val serverWantsMax = SemVer.parse(profile.compat.appMax)
if (app != null && serverWantsMin != null) {
if (app < serverWantsMin) {
return Result(
Verdict.APP_REFUSED,
"This server only accepts Echolot $serverWantsMin or newer; this app is " +
"$app. Update the app.",
)
}
if (serverWantsMax != null && app >= serverWantsMax) {
return Result(
Verdict.APP_REFUSED,
"This server refuses Echolot $serverWantsMax and newer; this app is $app. " +
"Use an older app, or a server that has caught up.",
)
}
}
return Result(Verdict.OK, null)
}
}
@@ -7,6 +7,16 @@ import kotlinx.serialization.json.Json
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
class UploadRefused(message: String) : Exception(message)
/**
* The server refused this app's version. Distinct from a transport failure and from an auth
* failure: nothing about the request was wrong, the two builds simply do not go together, and the
* message says which versions do.
*/
class VersionRefused(message: String) : Exception(message)
/**
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
@@ -16,8 +26,14 @@ import javax.net.ssl.HttpsURLConnection
*
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
* @param appVersion this build's SemVer, sent on every request so the server can refuse a build
* it cannot serve *before* a measurement half-runs (BuildConfig.VERSION_NAME).
*/
class ControlClient(private val controlUrl: String, pins: Set<String>) {
class ControlClient(
private val controlUrl: String,
pins: Set<String>,
private val appVersion: String = "",
) {
private val json = Json { ignoreUnknownKeys = true }
private val socketFactory = Pinning.sslContext(pins).socketFactory
@@ -30,12 +46,39 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
if (appVersion.isNotBlank()) conn.setRequestProperty(Compat.APP_VERSION_HEADER, appVersion)
return conn
}
/**
* 426 is the server saying "your version, not your request". Raised as a distinct exception
* from every call site so callers never report it as a network error — the whole value of the
* check is that the failure is legible.
*/
private fun checkVersion(conn: HttpsURLConnection, body: String) {
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
}
/** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */
private fun extractError(body: String): String? =
Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body)
?.groupValues?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
?.replace("\\\\", "\\")
/**
* Reads the response body, and turns a 426 into [VersionRefused] first.
*
* Every call goes through here, so the version check cannot be forgotten at a new call site —
* the alternative (a check per method) is exactly the kind of thing that gets missed once and
* then reports "upload failed: 426" to a user for a year.
*/
private fun body(conn: HttpsURLConnection): String {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
return stream?.bufferedReader()?.use { it.readText() } ?: ""
val text = stream?.bufferedReader()?.use { it.readText() } ?: ""
checkVersion(conn, text)
return text
}
private fun writeJson(conn: HttpsURLConnection, payload: String) {
@@ -63,21 +106,24 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
val conn = open("/v1/enroll", "POST", null)
conn.setRequestProperty("Authorization", "Bearer $token")
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
val text = body(conn) // reads and raises VersionRefused on 426
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} $text" }
return json.decodeFromString(EnrollResponse.serializer(), text)
}
fun profile(credential: String): Profile {
val conn = open("/v1/profile", "GET", credential)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(Profile.serializer(), body(conn))
val text = body(conn)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} $text" }
return json.decodeFromString(Profile.serializer(), text)
}
fun createSession(credential: String, target: String): SessionResponse {
val conn = open("/v1/sessions", "POST", credential)
writeJson(conn, """{"target":${jstr(target)}}""")
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(SessionResponse.serializer(), body(conn))
val text = body(conn)
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} $text" }
return json.decodeFromString(SessionResponse.serializer(), text)
}
/**
@@ -93,10 +139,51 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
return body
}
/**
* Uploads one measurement document. The body is sent exactly as given — whatever the
* anonymizer produced is what the server stores, so what the user was shown is what left
* the device. Returns the server's index entry as raw JSON.
*
* A refusal is not an error condition to retry: 403 means the operator's policy says no
* (uploads off, accounts required, or not anonymized enough), so it is surfaced as
* [UploadRefused] for the caller to show rather than swallow.
*/
fun uploadRun(credential: String, documentJson: String): String {
val conn = open("/v1/runs", "POST", credential)
writeJson(conn, documentJson)
val body = body(conn)
when (conn.responseCode) {
in 200..299 -> return body
403 -> throw UploadRefused(extractError(body) ?: body.take(200))
413 -> throw UploadRefused("run is larger than this server accepts: $body")
else -> error("upload failed: ${conn.responseCode} $body")
}
}
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode} $text" }
return text
}
fun getRun(credential: String, runId: String): String {
val conn = open("/v1/runs/$runId", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" }
return text
}
fun deleteRun(credential: String, runId: String) {
open("/v1/runs/$runId", "DELETE", credential).responseCode
}
fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
return body(conn)
val text = body(conn)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode} $text" }
return text
}
fun deleteSession(credential: String, sessionId: String) {
@@ -32,6 +32,40 @@ data class SelfTest(
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
)
/**
* The operator's upload rules, advertised in the profile so the app can present the choice
* honestly — greyed out with a reason when the server refuses, and pre-set to the server's
* minimum anonymization when it accepts — instead of discovering the policy by being rejected.
*/
@Serializable
data class UploadPolicy(
val mode: String = "off",
@SerialName("max_bytes") val maxBytes: Long = 0,
@SerialName("retention_days") val retentionDays: Int = 0,
@SerialName("max_runs_per_device") val maxRunsPerDevice: Int = 0,
@SerialName("min_anonymization") val minAnonymization: String = "full",
val reason: String? = null,
) {
val accepted: Boolean get() = mode == "anonymous" || mode == "account"
/** Why uploads are unavailable, in words a user can act on. */
fun refusalReason(): String? = when (mode) {
"off" -> reason ?: "This server does not accept uploaded runs."
"account" -> "This server only accepts uploads from signed-in accounts."
else -> null
}
}
/** The server's declaration of what it speaks and which app versions it will serve. */
@Serializable
data class CompatInfo(
@SerialName("protocol_version") val protocolVersion: String = "",
@SerialName("schema_version") val schemaVersion: String = "",
@SerialName("app_min") val appMin: String = "",
/** Exclusive; empty means the server sets no upper bound. */
@SerialName("app_max") val appMax: String = "",
)
@Serializable
data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0,
@@ -42,6 +76,8 @@ data class Profile(
@SerialName("canary_zone") val canaryZone: String = "",
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(),
val compat: CompatInfo = CompatInfo(),
) {
fun supports(capability: String) = capability in capabilities
}
@@ -12,6 +12,11 @@ import java.util.Base64
* A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1
* packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
* server target. Blocking; the caller owns threading.
*
* One instance per server session, for its whole lifetime. Sequence numbers start at zero here
* while the server's anti-replay window (§3.2) keeps counting, so a second instance sharing a
* session id has all its packets discarded as replays — and, because the server then never
* records the new source, any granted send still targets the socket that was closed.
*/
class ProbeSession(
private val credential: String,
@@ -0,0 +1,162 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The client half of the compatibility rule. Deliberately mirrors `compat_test.go`: the two
* implementations must agree on where the boundaries are, or one side refuses a peer the other
* accepts and the disagreement surfaces as an inexplicable failure in the field.
*/
class CompatTest {
@Test
fun parsesTheFormsThatActuallyReachUs() {
assertEquals(SemVer(1, 2, 3), SemVer.parse("1.2.3"))
assertEquals(SemVer(1, 2, 3), SemVer.parse("v1.2.3"))
assertEquals(SemVer(0, 4, 2), SemVer.parse("server-v0.4.2"))
assertEquals(SemVer(0, 2, 0), SemVer.parse(" 0.2.0 "))
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1"))
assertEquals(SemVer(1, 0, 0), SemVer.parse("1.0.0+build.7"))
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1+meta"))
// A pre-release identifier containing a "v" is not a tag prefix.
assertEquals(SemVer(1, 2, 3, "rcv1"), SemVer.parse("1.2.3-rcv1"))
for (bad in listOf("", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta", null)) {
assertNull(SemVer.parse(bad), "should not parse: $bad")
}
}
@Test
fun ordersPreReleasesBelowTheirRelease() {
fun lt(a: String, b: String) {
val x = assertNotNull(SemVer.parse(a))
val y = assertNotNull(SemVer.parse(b))
assertTrue(x < y, "$a should sort below $b")
assertTrue(y > x)
}
lt("0.9.9", "1.0.0")
lt("1.0.0", "1.0.1")
lt("1.0.0", "1.1.0")
lt("1.0.0-rc1", "1.0.0")
lt("1.0.0-rc1", "1.0.0-rc2")
assertEquals(0, SemVer.parse("1.2.3")!!.compareTo(SemVer.parse("v1.2.3")!!))
}
// Below 1.0.0 the minor is the breaking axis. Must match Go's NextBreaking exactly.
@Test
fun nextBreakingUsesTheMinorBelowOne() {
assertEquals("0.5.0", SemVer.parse("0.4.2")!!.nextBreaking().toString())
assertEquals("0.1.0", SemVer.parse("0.0.9")!!.nextBreaking().toString())
assertEquals("2.0.0", SemVer.parse("1.2.3")!!.nextBreaking().toString())
}
@Test
fun rangeIsMinInclusiveMaxExclusive() {
val r = VersionRange.of("0.2.0", "1.0.0")
for (s in listOf("0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1")) {
assertTrue(SemVer.parse(s)!! in r, "$s should be inside $r")
}
for (s in listOf("0.1.9", "1.0.0", "1.0.1", "2.0.0")) {
assertTrue(SemVer.parse(s)!! !in r, "$s should be outside $r")
}
assertTrue(SemVer.parse("99.0.0")!! in VersionRange.of("0.2.0", null), "empty max is unbounded")
}
private fun profile(serverVersion: String, appMin: String = "0.2.0", appMax: String = "1.0.0") =
Profile(
serverVersion = serverVersion,
compat = CompatInfo(protocolVersion = "1.0.0", appMin = appMin, appMax = appMax),
)
@Test
fun acceptsAServerInsideTheWindow() {
val r = Compat.check(profile("0.4.2"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.OK, r.verdict)
assertTrue(r.usable)
assertNull(r.message)
}
// 0.4.2 is the minimum for a concrete reason: older multi-homed servers mis-address granted
// sends and the client reports 100% downstream loss that never happened.
@Test
fun refusesAServerBelowTheMinimum() {
val r = Compat.check(profile("0.4.1"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.SERVER_TOO_OLD, r.verdict)
assertTrue(!r.usable)
assertTrue(r.message!!.contains("0.4.1") && r.message!!.contains("0.4.2"),
"the message must name both versions: ${r.message}")
}
@Test
fun refusesAServerFromANewerBreakingSeries() {
val r = Compat.check(profile("1.0.0"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.SERVER_TOO_NEW, r.verdict)
assertTrue(r.message!!.contains("Update the app"), "should tell the user what to do")
}
// Learning that the server will refuse us only when a measurement fails halfway is a much
// worse experience than being told before the run starts — so the check goes both ways.
@Test
fun detectsThatTheServerWouldRefuseThisApp() {
val old = Compat.check(profile("0.4.2", appMin = "0.5.0"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.APP_REFUSED, old.verdict)
assertTrue(old.message!!.contains("0.5.0"))
val tooNew = Compat.check(profile("0.4.2", appMax = "0.3.0"), appVersion = "0.4.0")
assertEquals(Compat.Verdict.APP_REFUSED, tooNew.verdict)
}
// A development build reports something unparseable. Locking a developer out of their own
// server would be a poor trade for a check meant to make failures clearer.
@Test
fun unknownVersionsAreUsableWithAnExplanation() {
val r = Compat.check(profile("dev"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.UNKNOWN, r.verdict)
assertTrue(r.usable, "an unidentifiable server must not be treated as incompatible")
assertNotNull(r.message)
}
@Test
fun aServerThatDeclaresNoWindowIsNotTreatedAsRefusingUs() {
// An older server predating the compat block sends nothing; absence must not read as
// a restriction.
val r = Compat.check(Profile(serverVersion = "0.4.2"), appVersion = "0.2.0")
assertEquals(Compat.Verdict.OK, r.verdict)
}
// The protocol version decides whether the builds *can* talk; the release window is only
// policy about whether they *may*. A protocol break must be reported as a protocol break,
// even when both release versions sit comfortably inside their windows.
@Test
fun aProtocolBreakIsReportedAsOne() {
val newer = Profile(
serverVersion = "0.9.0",
compat = CompatInfo(protocolVersion = "2.0.0", appMin = "0.2.0", appMax = "1.0.0"),
)
val r = Compat.check(newer, appVersion = "0.2.0")
assertEquals(Compat.Verdict.PROTOCOL_MISMATCH, r.verdict)
assertTrue(r.message!!.contains("2.0.0"))
// Same protocol series, different patch: fine. A protocol bugfix must not split a fleet.
val samePatch = Profile(
serverVersion = "0.9.0",
compat = CompatInfo(protocolVersion = "1.0.4", appMin = "0.2.0", appMax = "1.0.0"),
)
assertEquals(Compat.Verdict.OK, Compat.check(samePatch, appVersion = "0.2.0").verdict)
}
@Test
fun thisBuildsOwnBoundsAreWellFormed() {
val r = Compat.serverRange
assertEquals(SemVer.parse(Compat.MIN_SERVER), r.min)
assertEquals(SemVer.parse(Compat.MAX_SERVER), r.max)
assertTrue(r.min < r.max!!, "the built-in window must be non-empty")
}
}
+2
View File
@@ -24,6 +24,8 @@ rootProject.name = "echolot-app"
include(":core-protocol")
include(":core-measurement")
include(":core-engine")
include(":core-privacy")
include(":core-archive")
include(":core-probe")
include(":core-shizuku")
include(":app")
+11
View File
@@ -36,6 +36,7 @@ import (
"time"
"echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane"
@@ -131,6 +132,15 @@ func serve(cfg *config.Config) error {
"retention_days", cfg.UploadRetentionDays, "max_runs_per_device", cfg.UploadMaxRuns)
}
// A malformed window is fatal rather than ignored: an operator who set a restriction must
// not end up running without one because of a typo.
appRange, err := compat.ParseRange(cfg.MinAppVersion, cfg.MaxAppVersion)
if err != nil {
return fmt.Errorf("app version window: %w", err)
}
slog.Info("client compatibility", "accepts_app", appRange.String(),
"protocol", control.ProtocolVersion, "schema", control.SchemaVersion)
ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
@@ -140,6 +150,7 @@ func serve(cfg *config.Config) error {
BigSend: dp.BigSend,
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
Runs: runStore,
AppRange: appRange,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+9 -10
View File
@@ -39,13 +39,13 @@ const (
// Query is one logged canary lookup (spec §6 dns_canary shape).
type Query struct {
QName string `json:"qname"`
At time.Time `json:"at"`
ResolverIP string `json:"resolver_ip"`
Transport string `json:"transport"` // "udp" | "tcp"
EDNS edns `json:"edns"`
ECS string `json:"ecs,omitempty"`
CasePreserved bool `json:"case_preserved"`
QName string `json:"qname"`
At time.Time `json:"at"`
ResolverIP string `json:"resolver_ip"`
Transport string `json:"transport"` // "udp" | "tcp"
EDNS edns `json:"edns"`
ECS string `json:"ecs,omitempty"`
CasePreserved bool `json:"case_preserved"`
// qname_minimized is not reliably detectable authoritative-side without
// cross-query correlation; left false (TODO) rather than guessed.
QNameMinimized bool `json:"qname_minimized"`
@@ -59,8 +59,8 @@ type edns struct {
// Server is the authoritative responder for one canary zone.
type Server struct {
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
nsName string // this server's own name for NS/authority answers
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
nsName string // this server's own name for NS/authority answers
primaryV4 netip.Addr
primaryV6 netip.Addr
@@ -284,4 +284,3 @@ func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte {
}
return hdr
}
+1 -1
View File
@@ -21,7 +21,7 @@ func buildQuery(name string, qtype uint16, ednsBufsize int) []byte {
msg = binary.BigEndian.AppendUint16(msg, classIN)
if ednsBufsize > 0 {
binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT
msg = append(msg, 0) // root name
msg = append(msg, 0) // root name
msg = binary.BigEndian.AppendUint16(msg, typeOPT)
msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize))
msg = binary.BigEndian.AppendUint32(msg, 0)
+200
View File
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package compat decides whether two Echolot builds should talk to each other.
//
// Versions are SemVer. What is actually being checked, though, is not "is this release recent"
// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for
// that, and the proxy only holds because we bump the breaking axis when the contract changes.
// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must
// never strand a fleet, and the range must not need editing to ship a bugfix.
//
// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot
// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its
// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says.
package compat
import (
"fmt"
"strconv"
"strings"
)
// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of
// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0".
type Version struct {
Major, Minor, Patch int
Pre string
}
// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because
// those are the forms that actually reach this code: a tag, a --version output, and an HTTP
// header written by three different pieces of software.
func Parse(s string) (Version, bool) {
s = strings.TrimSpace(s)
// Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having
// no digits so a pre-release identifier that happens to contain a "v" is left alone.
if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) &&
s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") {
s = s[i+1:]
}
if plus := strings.IndexByte(s, '+'); plus >= 0 {
s = s[:plus]
}
var pre string
if dash := strings.IndexByte(s, '-'); dash >= 0 {
pre, s = s[dash+1:], s[:dash]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return Version{}, false
}
out := Version{Pre: pre}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return Version{}, false
}
switch i {
case 0:
out.Major = n
case 1:
out.Minor = n
case 2:
out.Patch = n
}
}
return out, true
}
func (v Version) String() string {
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
if v.Pre != "" {
s += "-" + v.Pre
}
return s
}
// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11);
// two pre-releases compare lexically, which is close enough for the identifiers we use.
func (v Version) Compare(o Version) int {
for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} {
if d != 0 {
return sign(d)
}
}
switch {
case v.Pre == o.Pre:
return 0
case v.Pre == "":
return 1
case o.Pre == "":
return -1
case v.Pre < o.Pre:
return -1
}
return 1
}
func (v Version) Less(o Version) bool { return v.Compare(o) < 0 }
// NextBreaking is the first version that may break compatibility with v.
//
// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's
// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a
// 0.5 server accept a 0.4 app that cannot speak to it.
func (v Version) NextBreaking() Version {
if v.Major == 0 {
return Version{Major: 0, Minor: v.Minor + 1}
}
return Version{Major: v.Major + 1}
}
// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded.
//
// Exclusive on the upper end because the useful bound is always "the version that broke it",
// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not.
type Range struct {
Min Version
Max Version
HasMax bool
}
func (r Range) Contains(v Version) bool {
if v.Less(r.Min) {
return false
}
if r.HasMax && !v.Less(r.Max) {
return false
}
return true
}
func (r Range) String() string {
if !r.HasMax {
return ">= " + r.Min.String()
}
return ">= " + r.Min.String() + ", < " + r.Max.String()
}
// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is
// an error rather than a silently ignored one: a typo in an operator's config must not quietly
// turn a restriction off.
func ParseRange(min, max string) (Range, error) {
lo, ok := Parse(min)
if !ok {
return Range{}, fmt.Errorf("bad minimum version %q", min)
}
if strings.TrimSpace(max) == "" {
return Range{Min: lo}, nil
}
hi, ok := Parse(max)
if !ok {
return Range{}, fmt.Errorf("bad maximum version %q", max)
}
if hi.Less(lo) || hi.Compare(lo) == 0 {
return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min)
}
return Range{Min: lo, Max: hi, HasMax: true}, nil
}
// Verdict is the outcome of a compatibility check.
type Verdict int
const (
OK Verdict = iota
TooOld
TooNew
// Unknown means the peer did not say, or said something unparseable — a development build,
// or a client too old to send its version at all.
Unknown
)
// Check reports whether peer falls in r, and explains the answer in words meant for a person.
// The message is deliberately actionable: it names both versions and what to do about it, since
// it is the only thing the user on the other end will see.
func Check(peer string, r Range, peerName string) (Verdict, string) {
v, ok := Parse(peer)
if !ok {
return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer)
}
switch {
case v.Less(r.Min):
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
peerName, v, r, peerName)
case r.HasMax && !v.Less(r.Max):
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or use a version of the %s within that range.",
peerName, v, r, peerName)
}
return OK, ""
}
func sign(d int) int {
if d < 0 {
return -1
}
if d > 0 {
return 1
}
return 0
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package compat
import "testing"
func TestParseAcceptsTheFormsThatActuallyReachUs(t *testing.T) {
cases := map[string]Version{
"1.2.3": {Major: 1, Minor: 2, Patch: 3},
"v1.2.3": {Major: 1, Minor: 2, Patch: 3},
"server-v0.4.2": {Minor: 4, Patch: 2},
" 0.2.0 ": {Minor: 2},
"1.0.0-rc1": {Major: 1, Pre: "rc1"},
"1.0.0+build.7": {Major: 1},
"1.0.0-rc1+meta": {Major: 1, Pre: "rc1"},
}
for in, want := range cases {
got, ok := Parse(in)
if !ok || got != want {
t.Errorf("Parse(%q) = %v,%v; want %v", in, got, ok, want)
}
}
// A pre-release identifier containing a "v" must not be mistaken for a tag prefix.
if got, ok := Parse("1.2.3-rcv1"); !ok || got.Pre != "rcv1" || got.Major != 1 {
t.Errorf("Parse(1.2.3-rcv1) = %v,%v", got, ok)
}
for _, bad := range []string{"", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta"} {
if _, ok := Parse(bad); ok {
t.Errorf("Parse(%q) should have failed", bad)
}
}
}
func TestCompareOrdersPreReleasesBelowTheirRelease(t *testing.T) {
lt := func(a, b string) {
t.Helper()
x, _ := Parse(a)
y, _ := Parse(b)
if x.Compare(y) != -1 || y.Compare(x) != 1 {
t.Errorf("expected %s < %s", a, b)
}
}
lt("0.9.9", "1.0.0")
lt("1.0.0", "1.0.1")
lt("1.0.0", "1.1.0")
lt("1.0.0-rc1", "1.0.0")
lt("1.0.0-rc1", "1.0.0-rc2")
a, _ := Parse("1.2.3")
b, _ := Parse("v1.2.3")
if a.Compare(b) != 0 {
t.Error("the same version written two ways must compare equal")
}
}
// Below 1.0.0 the minor is the breaking axis. Treating 1.0.0 as the next break for a 0.x build
// would let a 0.5 server accept a 0.4 client it cannot actually talk to.
func TestNextBreakingUsesTheMinorBelowOne(t *testing.T) {
cases := map[string]string{
"0.4.2": "0.5.0",
"0.0.9": "0.1.0",
"1.2.3": "2.0.0",
"2.0.0": "3.0.0",
}
for in, want := range cases {
v, _ := Parse(in)
if got := v.NextBreaking().String(); got != want {
t.Errorf("NextBreaking(%s) = %s, want %s", in, got, want)
}
}
}
func TestRangeIsMinInclusiveMaxExclusive(t *testing.T) {
r, err := ParseRange("0.2.0", "1.0.0")
if err != nil {
t.Fatal(err)
}
in := []string{"0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1"}
out := []string{"0.1.9", "1.0.0", "1.0.1", "2.0.0"}
for _, s := range in {
v, _ := Parse(s)
if !r.Contains(v) {
t.Errorf("%s should be inside %s", s, r)
}
}
for _, s := range out {
v, _ := Parse(s)
if r.Contains(v) {
t.Errorf("%s should be outside %s", s, r)
}
}
}
func TestUnboundedRangeHasNoCeiling(t *testing.T) {
r, err := ParseRange("0.2.0", "")
if err != nil {
t.Fatal(err)
}
v, _ := Parse("99.0.0")
if !r.Contains(v) {
t.Error("an empty maximum must mean unbounded")
}
}
// A typo in an operator's config must not silently disable the restriction it was meant to set.
func TestMalformedBoundsAreErrorsNotSilentPermissiveness(t *testing.T) {
for _, c := range [][2]string{
{"nonsense", "1.0.0"},
{"0.2.0", "nonsense"},
{"1.0.0", "0.9.0"}, // max below min
{"1.0.0", "1.0.0"}, // empty window: nothing could ever satisfy it
} {
if _, err := ParseRange(c[0], c[1]); err == nil {
t.Errorf("ParseRange(%q, %q) should have failed", c[0], c[1])
}
}
}
func TestCheckExplainsItself(t *testing.T) {
r, _ := ParseRange("0.2.0", "1.0.0")
if v, msg := Check("0.5.0", r, "app"); v != OK || msg != "" {
t.Errorf("in-range check should pass silently: %v %q", v, msg)
}
v, msg := Check("0.1.0", r, "app")
if v != TooOld {
t.Fatalf("want TooOld, got %v", v)
}
for _, want := range []string{"0.1.0", "0.2.0", "Update"} {
if !contains(msg, want) {
t.Errorf("the refusal must name %q so the user can act on it: %q", want, msg)
}
}
if v, _ := Check("1.4.0", r, "app"); v != TooNew {
t.Errorf("want TooNew, got %v", v)
}
// A development build reports "dev". Locking developers out of their own server would be a
// poor trade for a check that exists to prevent confusing failures.
if v, msg := Check("dev", r, "server"); v != Unknown || msg == "" {
t.Errorf("unparseable version should be Unknown with an explanation, got %v %q", v, msg)
}
}
func contains(h, n string) bool {
return len(h) >= len(n) && (h == n || len(n) == 0 || indexOf(h, n) >= 0)
}
func indexOf(h, n string) int {
for i := 0; i+len(n) <= len(h); i++ {
if h[i:i+len(n)] == n {
return i
}
}
return -1
}
+7
View File
@@ -58,6 +58,11 @@ type Config struct {
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
// Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
}
@@ -106,6 +111,8 @@ func Load(args []string) (*Config, *Actions, error) {
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/store"
)
func rangeOrDie(t *testing.T, min, max string) compat.Range {
t.Helper()
r, err := compat.ParseRange(min, max)
if err != nil {
t.Fatal(err)
}
return r
}
func TestGateRefusesOutOfRangeApps(t *testing.T) {
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
reached := false
h := s.requireCompatibleApp(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
for _, tc := range []struct {
version string
wantCode int
wantThru bool
}{
{"0.2.0", http.StatusOK, true}, // exactly the minimum is in range
{"0.9.9", http.StatusOK, true},
{"0.1.9", http.StatusUpgradeRequired, false}, // too old
{"1.0.0", http.StatusUpgradeRequired, false}, // maximum is exclusive
{"2.0.0", http.StatusUpgradeRequired, false}, // too new
{"", http.StatusOK, true}, // unknown: allowed, see below
{"dev", http.StatusOK, true}, // development build
} {
reached = false
req := httptest.NewRequest("GET", "/v1/sessions", nil)
if tc.version != "" {
req.Header.Set(AppVersionHeader, tc.version)
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != tc.wantCode || reached != tc.wantThru {
t.Errorf("app %q: got code=%d reached=%v, want code=%d reached=%v",
tc.version, rec.Code, reached, tc.wantCode, tc.wantThru)
}
}
}
// A refusal that does not say what version to install is only marginally better than a timeout.
func TestRefusalNamesTheAcceptedWindow(t *testing.T) {
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/runs", nil)
req.Header.Set(AppVersionHeader, "0.1.0")
s.requireCompatibleApp(func(http.ResponseWriter, *http.Request) {})(rec, req)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("refusal body is not JSON: %v", err)
}
for _, key := range []string{"error", "app_version", "accepts_app", "protocol_version"} {
if body[key] == nil || body[key] == "" {
t.Errorf("refusal omits %q, so the client cannot explain itself: %v", key, body)
}
}
if !strings.Contains(body["accepts_app"].(string), "0.2.0") {
t.Errorf("accepts_app should state the minimum: %v", body["accepts_app"])
}
}
// The profile is how a refused client learns which version it needs. Gating it would leave the
// user with a network error instead of an answer, which defeats the whole check.
func TestProfileIsReachableRegardlessOfVersion(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
s := &Server{
AppRange: rangeOrDie(t, "9.0.0", ""), // nothing current could satisfy this
Store: st,
}
req := httptest.NewRequest("GET", "/v1/profile", nil)
req.Header.Set(AppVersionHeader, "0.1.0")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
// The request carries no credential, so the handler answers 401 — the point is that it is
// the *handler* answering, not the version gate turning it into 426.
if rec.Code == http.StatusUpgradeRequired {
t.Fatal("the profile endpoint must never be gated on app version")
}
}
func TestZeroValueRangeFallsBackToTheBuiltInDefault(t *testing.T) {
s := &Server{} // nothing configured
got := s.appRange()
want := DefaultAppRange()
if got.Min != want.Min || got.HasMax != want.HasMax || got.Max != want.Max {
t.Fatalf("appRange() = %s, want the built-in default %s", got, want)
}
}
+94 -11
View File
@@ -24,6 +24,7 @@ import (
"strings"
"time"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session"
@@ -70,22 +71,87 @@ type Server struct {
// server's own egress isn't full-MTU, client MTU results measure the
// server, not the client.
ProvenGood func() (mtuOK, sysctlOK bool)
// AppRange is the app-version window this server will serve. Zero value means the built-in
// default (see DefaultAppRange).
AppRange compat.Range
}
// AppVersionHeader is how a client states its version. A client too old to send it is treated as
// unknown rather than refused: the check exists to turn confusing failures into clear ones, and
// refusing something we cannot identify achieves the opposite.
const AppVersionHeader = "X-Echolot-App-Version"
// ProtocolVersion is the wire contract (probe-protocol.md) this build implements. It is what the
// version window is really about; the release version is only a proxy for it.
const ProtocolVersion = "1.0.0"
// SchemaVersion is the measurement-document format this server can store.
const SchemaVersion = "1.0.0"
// DefaultAppRange: everything from the first app that speaks this protocol up to — but not
// including — the next breaking series. Bounds sit at breaking boundaries so shipping a patch
// never requires touching this.
func DefaultAppRange() compat.Range {
r, err := compat.ParseRange("0.2.0", "1.0.0")
if err != nil {
panic("built-in app range is malformed: " + err.Error())
}
return r
}
func (s *Server) appRange() compat.Range {
if s.AppRange.Min == (compat.Version{}) && !s.AppRange.HasMax {
return DefaultAppRange()
}
return s.AppRange
}
// requireCompatibleApp wraps a handler with the version window.
//
// Deliberately NOT applied to GET /v1/profile: that is where a client learns which version it
// should be. Gating it would leave a refused client with nothing to show its user but a timeout,
// which is precisely the confusion this check exists to remove.
func (s *Server) requireCompatibleApp(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
verdict, msg := compat.Check(r.Header.Get(AppVersionHeader), s.appRange(), "app")
switch verdict {
case compat.TooOld, compat.TooNew:
slog.Info("refused incompatible app", "app_version", r.Header.Get(AppVersionHeader),
"accepts", s.appRange().String(), "path", r.URL.Path)
// 426 says exactly this and nothing else; the body carries the window so the app can
// show the user the number to reach, not just that it failed.
writeJSON(w, http.StatusUpgradeRequired, map[string]any{
"error": msg,
"app_version": r.Header.Get(AppVersionHeader),
"accepts_app": s.appRange().String(),
"server_version": Version,
"protocol_version": ProtocolVersion,
})
return
}
next(w, r)
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/enroll", s.enroll)
// Always reachable, whatever the version window says: this is how a client discovers the
// window it has to satisfy.
mux.HandleFunc("GET /v1/profile", s.profile)
mux.HandleFunc("POST /v1/sessions", s.newSession)
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession)
mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations)
mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions)
mux.HandleFunc("POST /v1/echo", s.httpEcho)
mux.HandleFunc("GET /v1/tls-reference", s.tlsReference)
mux.HandleFunc("POST /v1/runs", s.uploadRun)
mux.HandleFunc("GET /v1/runs", s.listRuns)
mux.HandleFunc("GET /v1/runs/{id}", s.getRun)
mux.HandleFunc("DELETE /v1/runs/{id}", s.deleteRun)
gate := s.requireCompatibleApp
mux.HandleFunc("POST /v1/enroll", gate(s.enroll))
mux.HandleFunc("POST /v1/sessions", gate(s.newSession))
mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession))
mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations))
mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions))
mux.HandleFunc("POST /v1/echo", gate(s.httpEcho))
mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference))
mux.HandleFunc("POST /v1/runs", gate(s.uploadRun))
mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux
}
@@ -424,6 +490,14 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
// The app needs the upload rules before it offers the switch: whether uploads are
// accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(),
// What this build speaks, and which app versions it will serve. A client checks the
// server side of the same question against its own bounds.
"compat": map[string]any{
"protocol_version": ProtocolVersion,
"schema_version": SchemaVersion,
"app_min": s.appRange().Min.String(),
"app_max": maxOrEmpty(s.appRange()),
},
})
}
@@ -574,3 +648,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
// maxOrEmpty renders an unbounded ceiling as "" rather than as a sentinel version, so a client
// reading the profile cannot mistake a placeholder for a real bound.
func maxOrEmpty(r compat.Range) string {
if !r.HasMax {
return ""
}
return r.Max.String()
}
-1
View File
@@ -60,4 +60,3 @@ func TestTLSReferenceReturnsChain(t *testing.T) {
t.Fatalf("leaf DER not round-tripped: %x", first)
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"net"
"net/netip"
"testing"
)
// A multi-homed server must answer from the address the client has been talking to. Sending from
// a sibling address is silently dropped by the client's NAT or stateful firewall, and the client
// then reports downstream loss that never happened — a wrong measurement, which is worse than a
// failed one. This was a real bug: fmr binds two IPv4 addresses, the granted train went out from
// the one the session had never used, and every packet vanished in transit.
func TestConnForPrefersTheAddressTheSessionUsed(t *testing.T) {
// Two loopback-bound sockets stand in for the two service addresses.
a := mustListen(t, "127.0.0.1:0")
b := mustListen(t, "127.0.0.1:0")
defer a.Close()
defer b.Close()
srv := &Server{}
srv.conns = []*net.UDPConn{a, b}
client := netip.MustParseAddrPort("198.51.100.7:41000")
bLocal := b.LocalAddr().(*net.UDPAddr).AddrPort()
if got := srv.connFor(client, bLocal); got != b {
t.Fatalf("connFor picked the wrong socket: want the one the session used (%v)", bLocal)
}
// With no recorded local address (nothing received yet) any socket of the right family is
// the best available answer — but it must still be one, not nil.
if got := srv.connFor(client, netip.AddrPort{}); got == nil {
t.Fatal("connFor returned nil when a family match exists")
}
}
func TestConnForFallsBackByFamily(t *testing.T) {
v4 := mustListen(t, "127.0.0.1:0")
defer v4.Close()
srv := &Server{}
srv.conns = []*net.UDPConn{v4}
// A recorded local address that no longer matches any bound socket (a reload changed the
// binds) must not strand the session: fall back rather than return nil.
stale := netip.MustParseAddrPort("203.0.113.1:8442")
if got := srv.connFor(netip.MustParseAddrPort("198.51.100.7:41000"), stale); got != v4 {
t.Fatal("connFor should fall back to a family match when the recorded socket is gone")
}
// No IPv6 socket is bound, so an IPv6 target has no answer — nil, not an IPv4 socket.
if got := srv.connFor(netip.MustParseAddrPort("[2001:db8::1]:41000"), netip.AddrPort{}); got != nil {
t.Fatal("connFor returned an IPv4 socket for an IPv6 target")
}
}
func mustListen(t *testing.T, addr string) *net.UDPConn {
t.Helper()
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
t.Fatal(err)
}
c, err := net.ListenUDP("udp", ua)
if err != nil {
t.Fatal(err)
}
return c
}
+2 -2
View File
@@ -26,7 +26,7 @@ func (s *Server) DownTrain(sess *session.Session, g *session.Grant, count, sizeB
if !target.IsValid() {
return 0, fmt.Errorf("no observed data-plane source")
}
conn := s.connFor(target)
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return 0, fmt.Errorf("no data-plane socket matches target family")
}
@@ -75,7 +75,7 @@ func (s *Server) BigSend(sess *session.Session, g *session.Grant, sizes []int, d
if !target.IsValid() {
return nil, fmt.Errorf("no observed data-plane source")
}
conn := s.connFor(target)
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return nil, fmt.Errorf("no data-plane socket matches target family")
}
+27 -9
View File
@@ -73,9 +73,23 @@ func (s *Server) Serve(conn *net.UDPConn) error {
}
// connFor picks a retained socket whose family matches the target.
func (s *Server) connFor(target netip.AddrPort) *net.UDPConn {
// connFor picks the socket to send to target from.
//
// When the session recorded which local address it has been talking to (local), that socket wins
// outright. Falling back to "any socket of the right family" is only correct for a single-homed
// server: on a multi-homed one it sends from a sibling address the client's NAT has no mapping
// for, the packets are dropped in transit, and the client reports downstream loss that does not
// exist. That bug is invisible in a lab with one address, which is exactly why this is explicit.
func (s *Server) connFor(target, local netip.AddrPort) *net.UDPConn {
s.mu.Lock()
defer s.mu.Unlock()
if local.IsValid() {
for _, c := range s.conns {
if c.LocalAddr().(*net.UDPAddr).AddrPort() == local {
return c
}
}
}
want4 := target.Addr().Unmap().Is4()
for _, c := range s.conns {
la := c.LocalAddr().(*net.UDPAddr).AddrPort()
@@ -94,7 +108,7 @@ func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
if !target.IsValid() {
return fmt.Errorf("session has no observed data-plane source yet")
}
conn := s.connFor(target)
conn := s.connFor(target, sess.DataLocal())
if conn == nil {
return fmt.Errorf("no data-plane socket matches target family")
}
@@ -131,6 +145,9 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
return
}
sess.NoteDataSource(raddr)
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
sess.NoteDataLocal(la.AddrPort())
}
sess.RecordUDP(session.UDPObservation{
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
Src: raddr.String(), Size: len(pkt), Type: typ,
@@ -160,13 +177,14 @@ func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.S
}
// Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
// 0 8 t_rx_ns (server clock, process epoch)
// 8 8 t_tx_ns
// 16 16 observed source IP (v4-mapped when v4)
// 32 2 observed source port
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
// 35 1 received DSCP/ECN byte (0xFF = not observed)
// 36 4 received size
//
// 0 8 t_rx_ns (server clock, process epoch)
// 8 8 t_tx_ns
// 16 16 observed source IP (v4-mapped when v4)
// 32 2 observed source port
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
// 35 1 received DSCP/ECN byte (0xFF = not observed)
// 36 4 received size
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
b := make([]byte, 40)
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
+4 -4
View File
@@ -33,10 +33,10 @@ type Check struct {
// MTUResult is one egress path-MTU probe outcome.
type MTUResult struct {
Target string `json:"target"`
DiscoveredMTU int `json:"discovered_mtu"`
FullMTU bool `json:"full_mtu"` // >= 1500
Err string `json:"err,omitempty"`
Target string `json:"target"`
DiscoveredMTU int `json:"discovered_mtu"`
FullMTU bool `json:"full_mtu"` // >= 1500
Err string `json:"err,omitempty"`
}
// Report is the whole self-test.
+20
View File
@@ -27,6 +27,7 @@ type Session struct {
// Last data-plane source seen with a valid HMAC (NAT rebinding evidence).
mu sync.Mutex
dataSource netip.AddrPort
dataLocal netip.AddrPort
// Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus
// a bitmask of the 1024 preceding.
maxSeq uint32
@@ -192,6 +193,25 @@ func (s *Session) CheckSeq(seq uint32) bool {
return true
}
// DataLocal returns the server-side address that received this session's data-plane traffic.
//
// This matters more than it looks: a server bound to several addresses must send granted traffic
// back from the one the client has been talking to. Any stateful firewall or NAT in between has
// a mapping keyed on that exact pair, and a reply from a sibling address is dropped — which the
// client would then measure as downstream loss. See connFor.
func (s *Session) DataLocal() netip.AddrPort {
s.mu.Lock()
defer s.mu.Unlock()
return s.dataLocal
}
// NoteDataLocal records which of our own bound addresses saw this session's traffic.
func (s *Session) NoteDataLocal(ap netip.AddrPort) {
s.mu.Lock()
defer s.mu.Unlock()
s.dataLocal = ap
}
// NoteDataSource records the latest verified data-plane source.
func (s *Session) NoteDataSource(ap netip.AddrPort) {
s.mu.Lock()
+3 -3
View File
@@ -18,7 +18,7 @@ func buildClientHello(ciphers []uint16) []byte {
var body []byte
body = append(body, u16(0x0303)...) // client_version TLS1.2
body = append(body, make([]byte, 32)...) // random
body = append(body, 0) // session_id len 0
body = append(body, 0) // session_id len 0
// cipher suites
cs := []byte{}
for _, c := range ciphers {
@@ -36,8 +36,8 @@ func buildClientHello(ciphers []uint16) []byte {
exts = append(exts, data...)
}
// SNI: server_name_list -> host_name "x"
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
sni = append(sni, u16(1)...) // name len 1
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
sni = append(sni, u16(1)...) // name len 1
sni = append(sni, 'x')
addExt(0x0000, sni)
// ALPN: protocol_name_list -> "h2"
+1 -1
View File
@@ -33,7 +33,7 @@ func TestPlainEchoServerSpeaksFirst(t *testing.T) {
t.Fatalf("no greeting: %v", err)
}
var g struct {
TLS bool `json:"tls"`
TLS bool `json:"tls"`
Src string `json:"observed_src"`
}
if err := json.Unmarshal(line, &g); err != nil {
+74
View File
@@ -0,0 +1,74 @@
# Echolot website (`web/`)
Minimal single-page site for [echo-lot.app](https://echo-lot.app), served from Cloudflare
Workers. Static files in `public/` are served straight from the edge; the tiny Worker in
`src/index.js` only runs for paths that aren't files:
| Path | Behavior |
| ------------- | ------------------------------------------------------------------------ |
| `/apk` | 302 → newest `.apk` asset of the latest Gitea release (QR-code friendly) |
| `/apk.sha256` | 302 → the matching `.sha256` asset |
| `/api/latest` | JSON `{version, published_at, apk, sha256}` — the homepage's version readout |
| `/fdroid`, `/source` | 302 → the URLs configured in `wrangler.jsonc` vars |
The latest release is resolved from the Gitea API **at request time** (edge-cached 5 min), so
publishing a release — `git tag v0.2.0 && git push origin v0.2.0`, which triggers
`.gitea/workflows/release.yml` — is the only release step. The site never needs a redeploy for
a new version, and empty/unreachable values fall back to the homepage instead of 404ing.
Light/dark follows the OS (`prefers-color-scheme`), no toggle, no JS required for it. Colors
come from the branding palette (teal = instrument, single amber point = finding).
`public/assets/` (favicon, wordmark, social preview) are **copies** of `../assets/branding/`
that directory is the source of truth; re-copy after any branding change.
## Deploy
Everything is driven by [wrangler](https://developers.cloudflare.com/workers/wrangler/), config
in `wrangler.jsonc`. No build step, no node_modules to commit.
### One-time setup
1. In the Cloudflare dashboard, add **echo-lot.app** as a zone (and point the domain's
nameservers at Cloudflare). The `routes` in `wrangler.jsonc` use `custom_domain: true`, so
wrangler creates the DNS records for `echo-lot.app` and `www` automatically on first deploy —
the zone just has to exist in the same account.
2. Auth, either flavor:
- **Interactive:** `npx wrangler login` (opens the browser once, stores an OAuth token).
- **API token (also what CI uses):** dashboard → My Profile → API Tokens → create from the
**"Edit Cloudflare Workers"** template. Then:
```
$env:CLOUDFLARE_API_TOKEN = "..." # PowerShell; export ... on POSIX
$env:CLOUDFLARE_ACCOUNT_ID = "..." # dashboard → Workers & Pages, right sidebar
```
### Deploy
```
cd web
npx wrangler@4 deploy
```
That's it — uploads `src/index.js` + the `public/` assets, wires the custom domains. Useful
extras: `npx wrangler dev` (local preview at localhost:8787), `npx wrangler tail` (live logs),
`npx wrangler versions list`.
### CI deploy (Gitea Actions)
`.gitea/workflows/deploy-site.yml` runs `wrangler deploy` on every push to `main`/`master` that
touches `web/`. It stays inert until you add two repo secrets (Settings → Actions → Secrets):
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (same values as above).
Cloudflare's raw REST API (`PUT /accounts/:id/workers/scripts/...`) exists, but the assets
upload needs a manifest/session dance that wrangler already implements — use wrangler even in
automation.
## Config knobs (`wrangler.jsonc` → `vars`)
- `GITEA_REPO_API` — Gitea repo API base; releases must be publicly readable.
- `DOWNLOAD_URL` — manual `/apk` fallback while Gitea is unreachable.
- `FDROID_URL` — set when the F-Droid listing exists; until then `/fdroid` loops home.
- `SOURCE_URL` — public source mirror for the footer + `/source`.
Vars are plain (non-secret) config; change + `wrangler deploy` to apply.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0E2433"/>
<stop offset="1" stop-color="#071522"/>
</linearGradient>
</defs>
<rect width="96" height="96" rx="21" fill="url(#tile)"/>
<!-- the network, at rest -->
<g fill="#1E4A5C">
<circle cx="24" cy="24" r="2.6"/><circle cx="48" cy="24" r="2.6"/><circle cx="72" cy="24" r="2.6"/>
<circle cx="24" cy="48" r="2.6"/> <circle cx="72" cy="48" r="2.6"/>
<circle cx="24" cy="72" r="2.6"/><circle cx="48" cy="72" r="2.6"/><circle cx="72" cy="72" r="2.6"/>
</g>
<!-- one node, under examination -->
<circle cx="48" cy="48" r="8" fill="none" stroke="#FFB454" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="48" cy="48" r="4.5" fill="#FFB454"/>
<g fill="none" stroke="#35E0C4" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M 33 41 V 33 H 41"/>
<path d="M 55 33 H 63 V 41"/>
<path d="M 63 55 V 63 H 55"/>
<path d="M 41 63 H 33 V 55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For dark grounds. -->
<g fill="none" stroke="#E8F4F2" stroke-width="11" stroke-linecap="round">
<path d="M 0 -24 H 48"/>
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
<path d="M 140 -76 V 0"/>
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
<circle cx="234" cy="-24" r="24"/>
<path d="M 280 -76 V 0"/>
<circle cx="326" cy="-24" r="24" stroke="#35E0C4"/>
<path d="M 372 -48 H 402"/>
<path d="M 387 -68 V 0"/>
</g>
<!-- the finding -->
<circle cx="326" cy="-24" r="6.5" fill="#FFB454"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For light grounds. -->
<g fill="none" stroke="#1B3540" stroke-width="11" stroke-linecap="round">
<path d="M 0 -24 H 48"/>
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
<path d="M 140 -76 V 0"/>
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
<circle cx="234" cy="-24" r="24"/>
<path d="M 280 -76 V 0"/>
<circle cx="326" cy="-24" r="24" stroke="#0E9384"/>
<path d="M 372 -48 H 402"/>
<path d="M 387 -68 V 0"/>
</g>
<!-- the finding -->
<circle cx="326" cy="-24" r="6.5" fill="#E08A1E"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

+156 -87
View File
@@ -5,103 +5,124 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Echolot — depth soundings for your local network</title>
<title>Echolot — measure, don't guess</title>
<meta name="description" content="Free Android app for detecting and debugging local network issues: rogue DHCP, broken IPv6 RAs, MTU black holes, multicast loss, lying DNS. No root required.">
<meta property="og:title" content="Echolot">
<meta property="og:description" content="Depth soundings for your local network. F/OSS Android network diagnostics — no root required.">
<meta property="og:description" content="Measure, don't guess. F/OSS Android network diagnostics — no root required.">
<meta property="og:url" content="https://echo-lot.app/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23071A29'/%3E%3Cg fill='none' stroke='%23FFB454' stroke-width='2'%3E%3Ccircle cx='16' cy='16' r='3' fill='%23FFB454' stroke='none'/%3E%3Cpath d='M16 6a10 10 0 0 1 10 10'/%3E%3Cpath d='M16 1a15 15 0 0 1 15 15' opacity='.5'/%3E%3C/g%3E%3C/svg%3E">
<meta property="og:image" content="https://echo-lot.app/assets/social-preview.png">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#071522">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2F6F7">
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/icon.png" type="image/png" sizes="512x512">
<link rel="apple-touch-icon" href="/assets/icon.png">
<style>
/* Branding: teal is always the instrument (links, brackets, controls);
the single amber point is the finding (the focused node, the version
readout). Dark = the instrument's own display; light = the same tokens
on paper. Palette from assets/branding/. */
:root {
--depth-0: #0B2437; /* surface */
--depth-1: #092031; /* photic */
--depth-2: #071A29; /* mid */
--depth-3: #051320; /* floor */
--foam: #DCE9F1; /* primary text */
--slate: #8AA5B8; /* secondary text */
--grid: #16374E; /* hairlines, chart grid */
--ping: #FFB454; /* the one accent: sonar amber */
--ok: #7BC98F; /* verdict green, chips only */
color-scheme: dark;
--bg-0: #0C2130; /* top of page */
--bg-1: #071522; /* abyss — page floor, panel ground */
--tile: #0E2433; /* raised surfaces */
--foam: #E8F4F2; /* primary text */
--slate: #7DA2AC; /* secondary text */
--caption:#5E8B96; /* mono captions, from the banner */
--grid: #10303F; /* hairlines */
--rest: #1E4A5C; /* the network, at rest */
--teal: #35E0C4; /* instrument */
--on-teal:#04212B; /* text on teal */
--amber: #FFB454; /* the finding */
--mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-0: #F2F6F7;
--bg-1: #E4ECEF;
--tile: #EBF1F3;
--foam: #1B3540; /* ink, from wordmark-on-light */
--slate: #47656F;
--caption:#5E8B96;
--grid: #C4D3D8;
--rest: #9FB8C1;
--teal: #0E9384; /* instrument, printable contrast */
--on-teal:#F5FBFA;
--amber: #C77413; /* finding ink */
}
}
* { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--sans);
color: var(--foam);
background: linear-gradient(var(--depth-0), var(--depth-1) 30%, var(--depth-2) 65%, var(--depth-3));
background: linear-gradient(var(--bg-0), var(--bg-1) 70%);
min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--ping); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a { color: var(--teal); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 2px solid var(--ping); outline-offset: 3px; border-radius: 2px; }
:focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; border-radius: 2px; }
.col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; }
/* Depth ruler: fixed left margin scale, desktop only. Marks are set per-section
by scroll position purely decoratively — it is a ruler, not navigation. */
.ruler {
/* Graduated rule: the tick motif from the banner, vertical. Fixed left
margin, desktop only, purely decorative. */
.rule {
position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem;
border-right: 1px solid var(--grid);
font-family: var(--mono); font-size: .65rem; color: var(--slate);
display: none;
}
@media (min-width: 72rem) { .ruler { display: block; } }
.ruler span {
position: absolute; right: .5rem; transform: translateY(-50%);
}
.ruler span::after {
content: ""; position: absolute; right: -.55rem; top: 50%;
width: .35rem; height: 1px; background: var(--slate);
@media (min-width: 72rem) { .rule { display: block; } }
.rule::after {
content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: .4rem;
background: repeating-linear-gradient(to bottom, var(--rest) 0 1.5px, transparent 1.5px 60px);
}
header.hero { padding: 4.5rem 0 3rem; }
.wordmark {
font-family: var(--mono); font-size: .8rem; letter-spacing: .35em;
text-transform: uppercase; color: var(--slate);
}
.wordmark b { color: var(--ping); font-weight: 600; }
.wordmark { display: block; height: 30px; width: auto; }
h1 {
font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 650; letter-spacing: -.02em; line-height: 1.15;
margin: 1rem 0 .75rem; max-width: 30ch;
margin: 1.75rem 0 .75rem; max-width: 30ch;
}
.hero p.lede { color: var(--slate); max-width: 52ch; font-size: 1.05rem; }
.hero p.lede strong { color: var(--foam); font-weight: 600; }
/* Echogram: the signature. A chart-recorder trace of ping RTTs; the sweep
line is the sounder, the profile is the "seabed" the echoes draw. */
figure.echogram {
/* Focus panel: the signature, straight from the mark. The network at rest,
one node under examination — teal brackets are the instrument, the amber
point is the finding. */
figure.focus {
margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px;
background:
repeating-linear-gradient(to right, transparent 0 39px, var(--grid) 39px 40px),
repeating-linear-gradient(to bottom, transparent 0 31px, var(--grid) 31px 32px),
var(--depth-3);
background: var(--tile);
position: relative; overflow: hidden;
}
.echogram svg { display: block; width: 100%; height: auto; }
.echogram figcaption {
.focus svg { display: block; width: 100%; height: auto; }
.focus .rest-node { fill: var(--rest); }
.focus .bracket { fill: none; stroke: var(--teal); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.focus .finding { fill: var(--amber); }
.focus .halo { fill: none; stroke: var(--amber); stroke-width: 2; opacity: .3; }
.focus .readout { font-family: var(--mono); font-size: 11px; fill: var(--slate); }
.focus .readout .flag { fill: var(--amber); }
.focus .lead { stroke: var(--grid); stroke-width: 1; }
.focus figcaption {
position: absolute; top: .5rem; left: .75rem;
font-family: var(--mono); font-size: .65rem; color: var(--slate);
font-family: var(--mono); font-size: .65rem; color: var(--caption);
}
.sweep {
position: absolute; top: 0; bottom: 0; width: 1px;
background: var(--ping); opacity: .8;
box-shadow: 0 0 8px var(--ping);
animation: sweep 7s linear infinite;
}
@keyframes sweep { from { left: 0; } to { left: 100%; } }
@keyframes examine { 0%, 100% { opacity: .3; } 50% { opacity: .1; } }
.focus .halo { animation: examine 3.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.sweep { animation: none; left: 62%; }
.focus .halo { animation: none; }
html { scroll-behavior: auto; }
}
section { padding: 3.5rem 0 0; }
.eyebrow {
font-family: var(--mono); font-size: .7rem; letter-spacing: .25em;
text-transform: uppercase; color: var(--ping);
text-transform: uppercase; color: var(--teal);
}
h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; }
section > .col > p { color: var(--slate); max-width: 58ch; }
@@ -129,21 +150,26 @@
border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem;
color: var(--slate);
}
.tier b { color: var(--ok); font-weight: 600; }
.tier b { color: var(--teal); font-weight: 600; }
/* Install */
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1.5rem 0 1rem; }
.release {
font-family: var(--mono); font-size: .8rem; color: var(--slate);
margin-top: 1.25rem;
}
.release b { color: var(--amber); font-weight: 600; }
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1rem 0 1rem; }
.btn {
display: inline-block; padding: .7rem 1.3rem; border-radius: 4px;
font-weight: 600; text-decoration: none; font-size: .95rem;
}
.btn.primary { background: var(--ping); color: var(--depth-3); }
.btn.primary { background: var(--teal); color: var(--on-teal); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { border: 1px solid var(--grid); color: var(--foam); }
.btn.ghost:hover { border-color: var(--slate); }
.note {
font-size: .85rem; color: var(--slate);
border-left: 2px solid var(--ping); padding-left: .9rem; max-width: 52ch;
border-left: 2px solid var(--teal); padding-left: .9rem; max-width: 52ch;
}
.checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; }
@@ -157,45 +183,51 @@
</head>
<body>
<div class="ruler" aria-hidden="true">
<span style="top:6%">0 m</span>
<span style="top:28%">─ 20</span>
<span style="top:50%">─ 40</span>
<span style="top:72%">─ 60</span>
<span style="top:94%">─ 80</span>
</div>
<div class="rule" aria-hidden="true"></div>
<header class="hero">
<div class="col">
<p class="wordmark"><b></b> echo·lot <span aria-hidden="true">/ˈɛçolo:t/ — echo sounder</span></p>
<h1>Depth soundings for your local network.</h1>
<p class="lede">An echo sounder maps the seabed by timing returns. <strong>Echolot</strong> does the
same to your network: free Android diagnostics for the layer where things actually break —
<picture>
<source srcset="/assets/wordmark-on-dark.svg" media="(prefers-color-scheme: dark)">
<img class="wordmark" src="/assets/wordmark-on-light.svg" alt="echolot" width="125" height="30">
</picture>
<h1>Measure, don't guess.</h1>
<p class="lede">Free Android diagnostics for the layer where networks actually break.
<strong>Echolot</strong> takes the failure you can feel and pins it to a fact you can show —
<strong>no root required</strong>. Built for people who know what a neighbor table is.</p>
<figure class="echogram">
<figcaption>trace · icmp.ping4 · rtt ms ↓ / t →</figcaption>
<svg viewBox="0 0 720 190" role="img" aria-label="Chart-recorder style trace of ping round-trip times, drawn like a sonar seabed profile">
<!-- echo returns: the profile -->
<polyline fill="none" stroke="#FFB454" stroke-width="1.5" opacity=".9"
points="0,138 40,136 80,139 120,135 160,137 200,141 240,138 260,120 280,96 300,88 320,94 340,118 360,134 400,136 440,133 480,158 500,171 520,168 540,150 560,139 600,137 640,140 680,136 720,138"/>
<!-- second, fainter return (multipath) -->
<polyline fill="none" stroke="#FFB454" stroke-width="1" opacity=".25"
points="0,148 40,146 80,149 120,145 160,147 200,151 240,148 260,132 280,110 300,101 320,107 340,129 360,144 400,146 440,143 480,168 500,180 520,177 540,160 560,149 600,147 640,150 680,146 720,148"/>
<!-- dropped probes -->
<g fill="#8AA5B8" font-family="monospace" font-size="9">
<text x="497" y="30">×</text><text x="507" y="30">×</text>
<text x="288" y="30">▲ spike: wifi→cell handover</text>
<figure class="focus">
<figcaption>focus · dhcp.rogue_detect · tier:app</figcaption>
<svg viewBox="0 0 720 240" role="img" aria-label="A grid of network nodes at rest; one node is framed by viewfinder brackets, highlighted as a finding: two DHCP servers answered the same DISCOVER">
<!-- the network, at rest -->
<g class="rest-node">
<circle cx="120" cy="60" r="3"/><circle cx="240" cy="60" r="3"/><circle cx="360" cy="60" r="3"/><circle cx="480" cy="60" r="3"/><circle cx="600" cy="60" r="3"/>
<circle cx="120" cy="120" r="3"/><circle cx="480" cy="120" r="3"/><circle cx="600" cy="120" r="3"/>
<circle cx="120" cy="180" r="3"/><circle cx="240" cy="180" r="3"/><circle cx="360" cy="180" r="3"/><circle cx="480" cy="180" r="3"/><circle cx="600" cy="180" r="3"/>
</g>
<!-- one node, under examination -->
<circle class="halo" cx="240" cy="120" r="11"/>
<circle class="finding" cx="240" cy="120" r="5"/>
<g class="bracket">
<path d="M 222 111 V 102 H 231"/>
<path d="M 249 102 H 258 V 111"/>
<path d="M 258 129 V 138 H 249"/>
<path d="M 231 138 H 222 V 129"/>
</g>
<!-- the readout -->
<line class="lead" x1="262" y1="120" x2="296" y2="120"/>
<g class="readout">
<text x="304" y="112">DISCOVER → 2 OFFERs</text>
<text x="304" y="130">192.168.1.1 gw · <tspan class="flag">192.168.1.223 — who is this?</tspan></text>
</g>
</svg>
<div class="sweep" aria-hidden="true"></div>
</figure>
</div>
</header>
<section id="what">
<div class="col">
<p class="eyebrow">What it sounds out</p>
<p class="eyebrow">What it measures</p>
<h2>Signal bars lie. Timings don't.</h2>
<p>Most wifi apps show you signal strength and call it a diagnosis. The failures that ruin
home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router
@@ -234,15 +266,16 @@
<div class="col">
<p class="eyebrow">Install</p>
<h2>Get Echolot</h2>
<p class="release" id="release" hidden></p>
<div class="buttons">
<a class="btn primary" href="/apk">Download APK</a>
<a class="btn primary" id="dl-btn" href="/apk">Download APK</a>
<a class="btn ghost" href="/fdroid">F-Droid</a>
</div>
<p class="note"><strong>Pre-release.</strong> The capability prober is running on real
hardware; the production app is under construction. These links go live with the first
release — until then they loop back here. No mailing list, no tracker: check back, or watch
the <a href="/source">repository</a>.</p>
<p class="checksum">releases will ship with sha256sums + a signing key you can pin</p>
<p class="note" id="prerelease-note"><strong>Pre-release.</strong> The capability prober is
running on real hardware; the production app is under construction. These links go live with
the first release — until then they loop back here. No mailing list, no tracker: check back,
or watch the <a href="/source">repository</a>.</p>
<p class="checksum" id="checksum">releases will ship with sha256sums + a signing key you can pin</p>
</div>
</section>
@@ -253,5 +286,41 @@
</div>
</footer>
<script>
// Release readout: asks this site's own Worker (/api/latest, which proxies the
// Gitea "latest release" API, edge-cached). Progressive enhancement — with no
// JS, no network, or no release yet, the static pre-release copy above stands.
(async () => {
let rel;
try {
const res = await fetch("/api/latest");
if (!res.ok) return;
rel = await res.json();
} catch { return; }
if (!rel || !rel.available) return;
const line = document.getElementById("release");
const ver = document.createElement("b");
ver.textContent = rel.version;
line.append("» latest ", ver);
const date = (rel.published_at || "").slice(0, 10);
if (date) line.append(" · " + date);
if (rel.apk && rel.apk.size) {
line.append(" · " + (rel.apk.size / 1048576).toFixed(1) + " MiB");
}
line.hidden = false;
document.getElementById("prerelease-note").hidden = true;
if (rel.sha256) {
const c = document.getElementById("checksum");
c.textContent = "";
const a = document.createElement("a");
a.href = "/apk.sha256";
a.textContent = "sha256";
c.append(a, " · verify before you sideload");
}
})();
</script>
</body>
</html>
+47 -14
View File
@@ -3,28 +3,32 @@
// Everything under public/ is served straight from the edge without invoking
// this Worker. The Worker exists for the short stable URLs (/apk, /fdroid,
// /source) — short enough for a QR code — and to resolve "/apk" to the newest
// release asset at request time, so tagging a release in Gitea is the only
// publish step. No site redeploy, no URL to update.
// /source) — short enough for a QR code — and for /api/latest, which the
// homepage uses to show the current version. Both resolve the newest release
// from the Gitea API at request time, so tagging a release in Gitea is the
// only publish step. No site redeploy, no URL to update.
const STATIC_ROUTES = {
"/fdroid": "FDROID_URL",
"/source": "SOURCE_URL",
};
// Resolve the newest APK from the Gitea "latest release" API. Cached at the
// edge for 5 minutes so a release becomes visible quickly, while Gitea sees
// at most one API hit per POP per 5 min regardless of download traffic.
async function latestApkUrl(env) {
// Fetch the Gitea "latest release" object. Cached at the edge for 5 minutes so
// a new release becomes visible quickly, while Gitea sees at most one API hit
// per POP per 5 min regardless of traffic. Returns null on any failure —
// callers degrade to fallbacks rather than surfacing errors.
async function latestRelease(env) {
if (!env.GITEA_REPO_API) return null;
const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, {
headers: { Accept: "application/json", "User-Agent": "echolot-site" },
cf: { cacheTtl: 300, cacheEverything: true },
});
if (!res.ok) return null;
const rel = await res.json();
const apk = rel.assets?.find((a) => a.name?.endsWith(".apk"));
return apk?.browser_download_url ?? null;
return res.json();
}
function asset(rel, suffix) {
return rel?.assets?.find((a) => a.name?.endsWith(suffix)) ?? null;
}
function redirect(location) {
@@ -38,21 +42,50 @@ function redirect(location) {
});
}
function json(body, maxAge) {
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${maxAge}`,
},
});
}
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
const path = pathname.replace(/\/$/, "");
const fallback = new URL("/#install", request.url).toString();
if (path === "/apk" || path === "/download") {
// Order: live Gitea release → manual override → install section.
if (path === "/apk" || path === "/download" || path === "/apk.sha256") {
const suffix = path === "/apk.sha256" ? ".sha256" : ".apk";
let target = null;
try {
target = await latestApkUrl(env);
target = asset(await latestRelease(env), suffix)?.browser_download_url;
} catch {
// Gitea unreachable — fall through rather than 500 on a download link.
}
return redirect(target || env.DOWNLOAD_URL || fallback);
const override = suffix === ".apk" ? env.DOWNLOAD_URL : null;
return redirect(target || override || fallback);
}
if (path === "/api/latest") {
let rel = null;
try {
rel = await latestRelease(env);
} catch {}
const apk = asset(rel, ".apk");
if (!rel || !apk) return json({ available: false }, 60);
return json(
{
available: true,
version: rel.tag_name,
published_at: rel.published_at,
apk: { name: apk.name, size: apk.size, url: apk.browser_download_url },
sha256: Boolean(asset(rel, ".sha256")),
},
300,
);
}
const varName = STATIC_ROUTES[path];
+5 -5
View File
@@ -22,14 +22,14 @@
// its route fall back to the homepage's install section, so nothing 404s
// before the first release exists.
"vars": {
// Gitea repo API base, e.g. "https://git.example.net/api/v1/repos/mram/echolot".
// The repo (or at least its releases) must be publicly readable.
"GITEA_REPO_API": "",
// Manual override / fallback while GITEA_REPO_API is unset or unreachable.
// Gitea repo API base. The repo (or at least its releases) must be
// publicly readable for /apk and /api/latest to resolve.
"GITEA_REPO_API": "https://git.rambossek.at/api/v1/repos/EchoLot/echolot",
// Manual override / fallback while GITEA_REPO_API is unreachable.
"DOWNLOAD_URL": "",
// F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/
"FDROID_URL": "",
// Public source URL, the footer + /source target.
"SOURCE_URL": ""
"SOURCE_URL": "https://git.rambossek.at/EchoLot/echolot"
}
}