Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5b021b63 | ||
|
|
277e33da75 | ||
|
|
14e5fad1b2 |
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 = 2
|
||||
versionName = "0.2.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 }
|
||||
|
||||
@@ -88,6 +88,8 @@ class MainActivity : ComponentActivity() {
|
||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
||||
}
|
||||
},
|
||||
onCheckServer = vm::checkServer,
|
||||
serverStatus = vm.state.archiveStatus,
|
||||
onBack = { screen = Screen.RUN },
|
||||
)
|
||||
Screen.HISTORY -> HistoryScreen(
|
||||
|
||||
@@ -10,8 +10,10 @@ 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
|
||||
@@ -66,10 +68,46 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
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.
|
||||
*
|
||||
@@ -81,8 +119,14 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
|
||||
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
|
||||
return try {
|
||||
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin))
|
||||
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(
|
||||
@@ -93,6 +137,8 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
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) {
|
||||
|
||||
@@ -144,6 +144,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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"
|
||||
}
|
||||
@@ -195,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -46,6 +46,8 @@ fun SettingsScreen(
|
||||
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
|
||||
@@ -166,12 +168,26 @@ fun SettingsScreen(
|
||||
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) "Server configured."
|
||||
" " + 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))
|
||||
}
|
||||
|
||||
@@ -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,7 +114,6 @@ 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)
|
||||
@@ -102,7 +128,6 @@ class ServerMeasurement(
|
||||
tRx.add(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sent = cfg.echoCount
|
||||
val received = rtts.size
|
||||
|
||||
@@ -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")}")
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,11 +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).
|
||||
*/
|
||||
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
|
||||
class UploadRefused(message: String) : Exception(message)
|
||||
|
||||
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
|
||||
@@ -33,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) {
|
||||
@@ -66,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +154,7 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
val body = body(conn)
|
||||
when (conn.responseCode) {
|
||||
in 200..299 -> return body
|
||||
403 -> throw UploadRefused(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")
|
||||
}
|
||||
@@ -120,14 +163,16 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
/** Lists this device's runs stored on the server. */
|
||||
fun listRuns(credential: String): String {
|
||||
val conn = open("/v1/runs", "GET", credential)
|
||||
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
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)
|
||||
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" }
|
||||
return body(conn)
|
||||
val text = body(conn)
|
||||
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" }
|
||||
return text
|
||||
}
|
||||
|
||||
fun deleteRun(credential: String, runId: String) {
|
||||
@@ -136,8 +181,9 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -56,6 +56,16 @@ data class UploadPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -67,6 +77,7 @@ data class Profile(
|
||||
@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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 point at a %s within range.",
|
||||
peerName, v, r, peerName)
|
||||
}
|
||||
return OK, ""
|
||||
}
|
||||
|
||||
func sign(d int) int {
|
||||
if d < 0 {
|
||||
return -1
|
||||
}
|
||||
if d > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ 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)
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user