Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 33a6acb0bf compat: stop mangling refusal messages with HTML escapes
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
The server's 426 body reached the user as "needs \u003e= 0.2.0, \u003c 1.0.0":
Go escapes <, > and & by default for JSON destined for a page, which this is
not. Disabled at the encoder. The client now parses the error field rather than
pattern-matching it, so it survives whatever a future encoder decides to escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:41:00 +02:00
mrambossekandClaude Fable 5 9d6572bc33 compat: fix the too-new message's grammar, add a live gate test
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 30s
The generated refusal read "point at a app within range". Also adds
LiveCompatTest, which checks the half a unit test cannot reach: that two
independently-built artifacts agree on the window, that the profile stays
readable for a version the server refuses, and that both bounds are enforced.

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

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

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

Three rules that shape the rest:

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:38 +02:00
43 changed files with 2204 additions and 960 deletions
+48 -4
View File
@@ -32,10 +32,25 @@ Keep prober result IDs aligned with the measurement-schema test-type registry.
## Versioning ## Versioning
Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental
for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are
Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release + namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI →
registry image; the server on fmr can `--self-update` from those releases. 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 ## Layout
@@ -52,6 +67,29 @@ echolot-prober/ the capability prober (self-contained Gradle buil
ui/ProberScreen.kt result cards colored by verdict 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 ## Conventions
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway). - 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 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 port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
around this (or USB, if ever available). 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 — - 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 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`. Android Studio, its `jbr` directory works as `JAVA_HOME`.
+78
View File
@@ -543,3 +543,81 @@ Best available behavior, now implemented: the banner still opens Shizuku, but th
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options** exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless (`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
debugging must be enabled first for Shizuku's wireless start to work at all. debugging must be enabled first for Shizuku's wireless start to work at all.
### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
The client can measure a round trip and the largest packet it can *send*. It cannot measure the
largest packet it can *receive*, or downstream-only loss — those need the server to push, which is
exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
- **`session.Grant`** — created per action, bound at creation to the session's *observed*
data-plane source (no grant without a verified destination), clamped to server limits, with a
byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.
- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss,
reordering and inter-arrival spacing.
- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size
that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only
says whether fragments get through — a different fact, and the reason the schema has both
`mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the
startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by
our kernel is never read as a limit of the client's path.
Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery
up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a
3000 µs send interval.
#### Two bugs this shook out, both invisible in a single-homed lab
1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two
IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind
list. A train for a session established on `.150` left from `.151` and every packet was dropped
by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the
client saw none — reported as *100 % downstream loss*, a confident measurement of something
that never happened. Sessions now record which of our own bound addresses received their
traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the
family fallback.
2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at
zero client-side while the server's anti-replay window keeps counting, so every packet is
discarded as a replay — and because the server then never records the new source, the grant
still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole
run; `ProbeSession`'s doc comment states the constraint.
### Run archive, anonymizer and uploads (2026-08-01)
Three pieces, deliberately separate:
- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can
inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is
enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three
ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto
all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.
- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes
nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a
public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour
inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics,
statuses and finding codes. Pseudonyms are consistent within a document and — by default — not
across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for
people diffing their own history. Classification is one readable table, not annotations spread
across modules. 14 tests, each pinning a property someone's privacy depends on.
- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max
runs per device, and the *least* anonymization accepted. The profile advertises all of it so the
app presents the choice honestly instead of discovering the rules by being rejected. `account`
refuses today rather than falling back to anonymous: picking the strict setting before OIDC
lands must not silently mean the loose one.
**The archive holds the unredacted document; redaction happens on the way out, per upload.** The
local archive is the user's own data on their own device, and redacting it would destroy exactly
the detail that makes a week-old run worth keeping.
App-side: settings screen (archive limits, privacy level with a plain-language description of what
each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether
each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer
the user cannot inspect is only a promise.
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the
SSDP neighbour name and the free-text note are absent from what the server stores while the
finding code and the metrics survive, then deleted it.
### Still open
- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval.
- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
against the live server. On-device verification is the next step.
+65 -2
View File
@@ -192,14 +192,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference.
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above. 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). - 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). - `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"`. - 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`. - `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. 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. 2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design.
+19 -2
View File
@@ -8,6 +8,20 @@ plugins {
alias(libs.plugins.kotlin.serialization) 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 { android {
namespace = "app.echo_lot.app" namespace = "app.echo_lot.app"
compileSdk = 36 compileSdk = 36
@@ -16,12 +30,15 @@ android {
applicationId = "app.echo_lot.app" applicationId = "app.echo_lot.app"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = versionCodeOf(appVersionName)
versionName = "0.2.0" versionName = appVersionName
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true` // 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). // 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_URL", "\"http://89.185.109.150:443/report\"")
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"") 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 { buildTypes {
release { isMinifyEnabled = false } release { isMinifyEnabled = false }
@@ -88,6 +88,8 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { preview = vm.uploadPreview(r.id) } lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
} }
}, },
onCheckServer = vm::checkServer,
serverStatus = vm.state.archiveStatus,
onBack = { screen = Screen.RUN }, onBack = { screen = Screen.RUN },
) )
Screen.HISTORY -> HistoryScreen( Screen.HISTORY -> HistoryScreen(
@@ -10,8 +10,10 @@ import app.echo_lot.measurement.MeasurementDocument
import app.echo_lot.privacy.Anonymizer import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.Compat
import app.echo_lot.protocol.ControlClient import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused import app.echo_lot.protocol.UploadRefused
import app.echo_lot.protocol.VersionRefused
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
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 data class Sent(val serverName: String, val detail: String) : UploadOutcome
/** The operator's policy says no. Not retryable, and not the user's fault. */ /** The operator's policy says no. Not retryable, and not the user's fault. */
data class Refused(val reason: String) : UploadOutcome 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 class Failed(val detail: String) : UploadOutcome
data object NotConfigured : 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. * 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 if (!settings.serverConfigured) return UploadOutcome.NotConfigured
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive") val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
return try { return try {
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin)) val client = client()
val profile = client.profile(settings.serverCredential) 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) } profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
val level = PrivacyLevel.max( val level = PrivacyLevel.max(
@@ -93,6 +137,8 @@ class RunStore(context: Context, private val settings: Settings) {
val reply = client.uploadRun(settings.serverCredential, body) val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name) archive.markUploaded(runId, profile.name)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") 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) { } catch (e: UploadRefused) {
UploadOutcome.Refused(e.message ?: "refused by the server") UploadOutcome.Refused(e.message ?: "refused by the server")
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -144,6 +144,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private fun describe(o: RunStore.UploadOutcome): String = when (o) { private fun describe(o: RunStore.UploadOutcome): String = when (o) {
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})" is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}" 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}" is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded" 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() 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. */ /** Re-applies retention after the user changes the limits. */
fun applyRetention() { fun applyRetention() {
viewModelScope.launch { viewModelScope.launch {
@@ -46,6 +46,8 @@ fun SettingsScreen(
onApplyRetention: () -> Unit, onApplyRetention: () -> Unit,
onDeleteAll: () -> Unit, onDeleteAll: () -> Unit,
onPreviewUpload: () -> Unit, onPreviewUpload: () -> Unit,
onCheckServer: () -> Unit,
serverStatus: String?,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
// SharedPreferences is not observable, so mirror each value into Compose state and write // 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), textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row(verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onCheckServer) { Text("Check server") }
Text( Text(
if (settings.serverConfigured) "Server configured." " " + if (settings.serverConfigured) "Configured."
else "Uploads stay off until all three fields are set.", else "Uploads stay off until all three fields are set.",
style = MaterialTheme.typography.bodySmall, 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)) 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 udpPort: Int,
val echoCount: Int = 20, val echoCount: Int = 20,
val echoPaddingBytes: Int = 64, 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 { fun run(cfg: Config): MeasurementDocument {
@@ -57,11 +63,32 @@ class ServerMeasurement(
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort), 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) control.deleteSession(cfg.credential, session.sessionId)
val summary = Verdicts.derive(listOf(test), findings) val summary = Verdicts.derive(tests, allFindings)
return MeasurementDocument( return MeasurementDocument(
run = Run( run = Run(
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(), id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
@@ -70,14 +97,14 @@ class ServerMeasurement(
tiers = Tiers(app = true), tiers = Tiers(app = true),
), ),
serverSessions = listOf(serverSession), serverSessions = listOf(serverSession),
tests = listOf(test), tests = tests,
findings = findings, findings = allFindings,
summary = summary, summary = summary,
) )
} }
private fun echoTrain( 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>> { ): Pair<Test, List<Finding>> {
val testId = ids.uuid() val testId = ids.uuid()
val seqs = ArrayList<Int>() val seqs = ArrayList<Int>()
@@ -87,7 +114,6 @@ class ServerMeasurement(
val rtts = ArrayList<Double>() val rtts = ArrayList<Double>()
val observedPorts = LinkedHashSet<Int>() val observedPorts = LinkedHashSet<Int>()
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
for (i in 0 until cfg.echoCount) { for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes) val r = ps.echo(cfg.echoPaddingBytes)
@@ -102,7 +128,6 @@ class ServerMeasurement(
tRx.add(null) tRx.add(null)
} }
} }
}
val sent = cfg.echoCount val sent = cfg.echoCount
val received = rtts.size val received = rtts.size
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.Compat
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.VersionRefused
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Checks the version gate against a LIVE server — the half that unit tests cannot reach, because
* the whole point is that two independently-built artifacts agree. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveCompatTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private fun clientAs(version: String) = ControlClient(url!!, setOf(pin!!), version)
@Test
fun theServerAdvertisesAndEnforcesItsWindow() {
if (url == null || pin == null || cred == null) {
println("LiveCompatTest skipped (no ECHOLOT_LIVE_* env)"); return
}
// The profile must state the window — without it the app cannot pre-empt a refusal.
val profile = clientAs("0.2.0").profile(cred)
println("server ${profile.serverVersion} protocol=${profile.compat.protocolVersion} " +
"accepts app [${profile.compat.appMin}, ${profile.compat.appMax})")
assertTrue(profile.compat.protocolVersion.isNotBlank(), "profile omits protocol_version")
assertTrue(profile.compat.appMin.isNotBlank(), "profile omits app_min")
// This build must be inside it, or every other live test here is meaningless.
val verdict = Compat.check(profile, "0.2.0")
assertEquals(Compat.Verdict.OK, verdict.verdict, verdict.message ?: "")
// The profile stays reachable for a version the server would otherwise refuse: that is
// how a refused client discovers what it needs.
val ancient = clientAs("0.1.0")
val stillReadable = ancient.profile(cred)
assertEquals(profile.serverVersion, stillReadable.serverVersion,
"the profile endpoint must never be gated on app version")
// And a gated endpoint refuses it, with a message naming the window.
try {
ancient.createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an out-of-window app")
} catch (e: VersionRefused) {
val msg = assertNotNull(e.message)
println("refused as expected: $msg")
assertTrue(msg.contains("0.1.0"), "refusal should name the offending version: $msg")
assertTrue(msg.contains(profile.compat.appMin), "refusal should name the window: $msg")
}
// Too new is refused the same way — the window is a range, not a floor.
try {
clientAs("99.0.0").createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an app above its window")
} catch (e: VersionRefused) {
println("too-new refused as expected: ${e.message}")
}
}
}
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not
* just that packets moved: the tests must carry recomputable metrics and land on the right test
* types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*.
*/
class LiveDownstreamTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@JTest
fun producesDownstreamTestsAndFindings() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (tests, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo() // prime: the grant binds to the source the server has actually observed
DownstreamMeasurement(SystemIdSource())
.run(cred, session.sessionId, control, ps, sessionRef = "sess-1")
}
control.deleteSession(cred, session.sessionId)
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train")
val byType = tests.associateBy { it.type }
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL,
"DF probe did not deliver anything: ${pmtud.status}")
val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull()
assertNotNull(pathMtu, "pmtud_down must report a path MTU")
assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu")
println("downstream path MTU = $pathMtu bytes")
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
assertTrue(received > 0, "no downstream train packets arrived")
println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " +
"reordered=${train.metrics?.get("reordered_packets")}")
}
}
@@ -47,8 +47,11 @@ class LiveMeasurementTest {
assertEquals(1, doc.serverSessions.size) assertEquals(1, doc.serverSessions.size)
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe")) assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
val test = doc.tests.single() // A full run is the echo train plus the three downstream tests; assert on the one this
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type) // 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, assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
"expected replies from live server, got ${test.status}") "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" const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
// train // train
const val TRAIN_UDP_UPDOWN = "train.udp_updown" 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 // mtu
const val MTU_PMTUD_UP = "mtu.pmtud_up" const val MTU_PMTUD_UP = "mtu.pmtud_up"
const val MTU_PMTUD_DOWN = "mtu.pmtud_down" 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)
}
}
@@ -4,9 +4,21 @@
package app.echo_lot.protocol package app.echo_lot.protocol
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URL import java.net.URL
import javax.net.ssl.HttpsURLConnection 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 * The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike * SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
@@ -16,11 +28,14 @@ import javax.net.ssl.HttpsURLConnection
* *
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443" * @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 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 ControlClient(
class UploadRefused(message: String) : Exception(message) 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 json = Json { ignoreUnknownKeys = true }
private val socketFactory = Pinning.sslContext(pins).socketFactory private val socketFactory = Pinning.sslContext(pins).socketFactory
@@ -33,12 +48,42 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
conn.connectTimeout = 10_000 conn.connectTimeout = 10_000
conn.readTimeout = 10_000 conn.readTimeout = 10_000
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") } credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
if (appVersion.isNotBlank()) conn.setRequestProperty(Compat.APP_VERSION_HEADER, appVersion)
return conn 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.
*
* Parsed rather than pattern-matched: an encoder may legitimately escape characters in the
* message (Go escapes ">" by default), and a regex hands the user "needs \u003e= 0.2.0".
* The parser knows how to undo every escape; a regex would have to be taught each one.
*/
private fun extractError(body: String): String? = runCatching {
json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content
}.getOrNull()
/**
* 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 { private fun body(conn: HttpsURLConnection): String {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream 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) { private fun writeJson(conn: HttpsURLConnection, payload: String) {
@@ -66,21 +111,24 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
val conn = open("/v1/enroll", "POST", null) val conn = open("/v1/enroll", "POST", null)
conn.setRequestProperty("Authorization", "Bearer $token") conn.setRequestProperty("Authorization", "Bearer $token")
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}") writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" } val text = body(conn) // reads and raises VersionRefused on 426
return json.decodeFromString(EnrollResponse.serializer(), body(conn)) check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} $text" }
return json.decodeFromString(EnrollResponse.serializer(), text)
} }
fun profile(credential: String): Profile { fun profile(credential: String): Profile {
val conn = open("/v1/profile", "GET", credential) val conn = open("/v1/profile", "GET", credential)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" } val text = body(conn)
return json.decodeFromString(Profile.serializer(), body(conn)) check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} $text" }
return json.decodeFromString(Profile.serializer(), text)
} }
fun createSession(credential: String, target: String): SessionResponse { fun createSession(credential: String, target: String): SessionResponse {
val conn = open("/v1/sessions", "POST", credential) val conn = open("/v1/sessions", "POST", credential)
writeJson(conn, """{"target":${jstr(target)}}""") writeJson(conn, """{"target":${jstr(target)}}""")
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" } val text = body(conn)
return json.decodeFromString(SessionResponse.serializer(), body(conn)) check(conn.responseCode == 201) { "session failed: ${conn.responseCode} $text" }
return json.decodeFromString(SessionResponse.serializer(), text)
} }
/** /**
@@ -111,7 +159,7 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
val body = body(conn) val body = body(conn)
when (conn.responseCode) { when (conn.responseCode) {
in 200..299 -> return body 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") 413 -> throw UploadRefused("run is larger than this server accepts: $body")
else -> error("upload failed: ${conn.responseCode} $body") else -> error("upload failed: ${conn.responseCode} $body")
} }
@@ -120,14 +168,16 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
/** Lists this device's runs stored on the server. */ /** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String { fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential) val conn = open("/v1/runs", "GET", credential)
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" } val text = body(conn)
return body(conn) check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode} $text" }
return text
} }
fun getRun(credential: String, runId: String): String { fun getRun(credential: String, runId: String): String {
val conn = open("/v1/runs/$runId", "GET", credential) val conn = open("/v1/runs/$runId", "GET", credential)
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" } val text = body(conn)
return body(conn) check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" }
return text
} }
fun deleteRun(credential: String, runId: String) { fun deleteRun(credential: String, runId: String) {
@@ -136,8 +186,9 @@ class ControlClient(private val controlUrl: String, pins: Set<String>) {
fun observations(credential: String, sessionId: String): String { fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" } val text = body(conn)
return body(conn) check(conn.responseCode == 200) { "observations failed: ${conn.responseCode} $text" }
return text
} }
fun deleteSession(credential: String, sessionId: String) { 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 @Serializable
data class Profile( data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0, @SerialName("profile_version") val profileVersion: Int = 0,
@@ -67,6 +77,7 @@ data class Profile(
@SerialName("server_selftest") val serverSelftest: SelfTest? = null, @SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(), val pins: List<String> = emptyList(),
val uploads: UploadPolicy = UploadPolicy(), val uploads: UploadPolicy = UploadPolicy(),
val compat: CompatInfo = CompatInfo(),
) { ) {
fun supports(capability: String) = capability in capabilities 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 * 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 * packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
* server target. Blocking; the caller owns threading. * 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( class ProbeSession(
private val credential: String, 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")
}
}
+11
View File
@@ -36,6 +36,7 @@ import (
"time" "time"
"echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config" "echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control" "echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane" "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) "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{ ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name, Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
@@ -140,6 +150,7 @@ func serve(cfg *config.Config) error {
BigSend: dp.BigSend, BigSend: dp.BigSend,
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) }, TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
Runs: runStore, Runs: runStore,
AppRange: appRange,
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+200
View File
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package compat decides whether two Echolot builds should talk to each other.
//
// Versions are SemVer. What is actually being checked, though, is not "is this release recent"
// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for
// that, and the proxy only holds because we bump the breaking axis when the contract changes.
// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must
// never strand a fleet, and the range must not need editing to ship a bugfix.
//
// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot
// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its
// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says.
package compat
import (
"fmt"
"strconv"
"strings"
)
// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of
// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0".
type Version struct {
Major, Minor, Patch int
Pre string
}
// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because
// those are the forms that actually reach this code: a tag, a --version output, and an HTTP
// header written by three different pieces of software.
func Parse(s string) (Version, bool) {
s = strings.TrimSpace(s)
// Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having
// no digits so a pre-release identifier that happens to contain a "v" is left alone.
if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) &&
s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") {
s = s[i+1:]
}
if plus := strings.IndexByte(s, '+'); plus >= 0 {
s = s[:plus]
}
var pre string
if dash := strings.IndexByte(s, '-'); dash >= 0 {
pre, s = s[dash+1:], s[:dash]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return Version{}, false
}
out := Version{Pre: pre}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return Version{}, false
}
switch i {
case 0:
out.Major = n
case 1:
out.Minor = n
case 2:
out.Patch = n
}
}
return out, true
}
func (v Version) String() string {
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
if v.Pre != "" {
s += "-" + v.Pre
}
return s
}
// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11);
// two pre-releases compare lexically, which is close enough for the identifiers we use.
func (v Version) Compare(o Version) int {
for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} {
if d != 0 {
return sign(d)
}
}
switch {
case v.Pre == o.Pre:
return 0
case v.Pre == "":
return 1
case o.Pre == "":
return -1
case v.Pre < o.Pre:
return -1
}
return 1
}
func (v Version) Less(o Version) bool { return v.Compare(o) < 0 }
// NextBreaking is the first version that may break compatibility with v.
//
// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's
// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a
// 0.5 server accept a 0.4 app that cannot speak to it.
func (v Version) NextBreaking() Version {
if v.Major == 0 {
return Version{Major: 0, Minor: v.Minor + 1}
}
return Version{Major: v.Major + 1}
}
// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded.
//
// Exclusive on the upper end because the useful bound is always "the version that broke it",
// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not.
type Range struct {
Min Version
Max Version
HasMax bool
}
func (r Range) Contains(v Version) bool {
if v.Less(r.Min) {
return false
}
if r.HasMax && !v.Less(r.Max) {
return false
}
return true
}
func (r Range) String() string {
if !r.HasMax {
return ">= " + r.Min.String()
}
return ">= " + r.Min.String() + ", < " + r.Max.String()
}
// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is
// an error rather than a silently ignored one: a typo in an operator's config must not quietly
// turn a restriction off.
func ParseRange(min, max string) (Range, error) {
lo, ok := Parse(min)
if !ok {
return Range{}, fmt.Errorf("bad minimum version %q", min)
}
if strings.TrimSpace(max) == "" {
return Range{Min: lo}, nil
}
hi, ok := Parse(max)
if !ok {
return Range{}, fmt.Errorf("bad maximum version %q", max)
}
if hi.Less(lo) || hi.Compare(lo) == 0 {
return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min)
}
return Range{Min: lo, Max: hi, HasMax: true}, nil
}
// Verdict is the outcome of a compatibility check.
type Verdict int
const (
OK Verdict = iota
TooOld
TooNew
// Unknown means the peer did not say, or said something unparseable — a development build,
// or a client too old to send its version at all.
Unknown
)
// Check reports whether peer falls in r, and explains the answer in words meant for a person.
// The message is deliberately actionable: it names both versions and what to do about it, since
// it is the only thing the user on the other end will see.
func Check(peer string, r Range, peerName string) (Verdict, string) {
v, ok := Parse(peer)
if !ok {
return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer)
}
switch {
case v.Less(r.Min):
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
peerName, v, r, peerName)
case r.HasMax && !v.Less(r.Max):
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or use a version of the %s within that range.",
peerName, v, r, peerName)
}
return OK, ""
}
func sign(d int) int {
if d < 0 {
return -1
}
if d > 0 {
return 1
}
return 0
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package compat
import "testing"
func TestParseAcceptsTheFormsThatActuallyReachUs(t *testing.T) {
cases := map[string]Version{
"1.2.3": {Major: 1, Minor: 2, Patch: 3},
"v1.2.3": {Major: 1, Minor: 2, Patch: 3},
"server-v0.4.2": {Minor: 4, Patch: 2},
" 0.2.0 ": {Minor: 2},
"1.0.0-rc1": {Major: 1, Pre: "rc1"},
"1.0.0+build.7": {Major: 1},
"1.0.0-rc1+meta": {Major: 1, Pre: "rc1"},
}
for in, want := range cases {
got, ok := Parse(in)
if !ok || got != want {
t.Errorf("Parse(%q) = %v,%v; want %v", in, got, ok, want)
}
}
// A pre-release identifier containing a "v" must not be mistaken for a tag prefix.
if got, ok := Parse("1.2.3-rcv1"); !ok || got.Pre != "rcv1" || got.Major != 1 {
t.Errorf("Parse(1.2.3-rcv1) = %v,%v", got, ok)
}
for _, bad := range []string{"", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta"} {
if _, ok := Parse(bad); ok {
t.Errorf("Parse(%q) should have failed", bad)
}
}
}
func TestCompareOrdersPreReleasesBelowTheirRelease(t *testing.T) {
lt := func(a, b string) {
t.Helper()
x, _ := Parse(a)
y, _ := Parse(b)
if x.Compare(y) != -1 || y.Compare(x) != 1 {
t.Errorf("expected %s < %s", a, b)
}
}
lt("0.9.9", "1.0.0")
lt("1.0.0", "1.0.1")
lt("1.0.0", "1.1.0")
lt("1.0.0-rc1", "1.0.0")
lt("1.0.0-rc1", "1.0.0-rc2")
a, _ := Parse("1.2.3")
b, _ := Parse("v1.2.3")
if a.Compare(b) != 0 {
t.Error("the same version written two ways must compare equal")
}
}
// Below 1.0.0 the minor is the breaking axis. Treating 1.0.0 as the next break for a 0.x build
// would let a 0.5 server accept a 0.4 client it cannot actually talk to.
func TestNextBreakingUsesTheMinorBelowOne(t *testing.T) {
cases := map[string]string{
"0.4.2": "0.5.0",
"0.0.9": "0.1.0",
"1.2.3": "2.0.0",
"2.0.0": "3.0.0",
}
for in, want := range cases {
v, _ := Parse(in)
if got := v.NextBreaking().String(); got != want {
t.Errorf("NextBreaking(%s) = %s, want %s", in, got, want)
}
}
}
func TestRangeIsMinInclusiveMaxExclusive(t *testing.T) {
r, err := ParseRange("0.2.0", "1.0.0")
if err != nil {
t.Fatal(err)
}
in := []string{"0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1"}
out := []string{"0.1.9", "1.0.0", "1.0.1", "2.0.0"}
for _, s := range in {
v, _ := Parse(s)
if !r.Contains(v) {
t.Errorf("%s should be inside %s", s, r)
}
}
for _, s := range out {
v, _ := Parse(s)
if r.Contains(v) {
t.Errorf("%s should be outside %s", s, r)
}
}
}
func TestUnboundedRangeHasNoCeiling(t *testing.T) {
r, err := ParseRange("0.2.0", "")
if err != nil {
t.Fatal(err)
}
v, _ := Parse("99.0.0")
if !r.Contains(v) {
t.Error("an empty maximum must mean unbounded")
}
}
// A typo in an operator's config must not silently disable the restriction it was meant to set.
func TestMalformedBoundsAreErrorsNotSilentPermissiveness(t *testing.T) {
for _, c := range [][2]string{
{"nonsense", "1.0.0"},
{"0.2.0", "nonsense"},
{"1.0.0", "0.9.0"}, // max below min
{"1.0.0", "1.0.0"}, // empty window: nothing could ever satisfy it
} {
if _, err := ParseRange(c[0], c[1]); err == nil {
t.Errorf("ParseRange(%q, %q) should have failed", c[0], c[1])
}
}
}
func TestCheckExplainsItself(t *testing.T) {
r, _ := ParseRange("0.2.0", "1.0.0")
if v, msg := Check("0.5.0", r, "app"); v != OK || msg != "" {
t.Errorf("in-range check should pass silently: %v %q", v, msg)
}
v, msg := Check("0.1.0", r, "app")
if v != TooOld {
t.Fatalf("want TooOld, got %v", v)
}
for _, want := range []string{"0.1.0", "0.2.0", "Update"} {
if !contains(msg, want) {
t.Errorf("the refusal must name %q so the user can act on it: %q", want, msg)
}
}
if v, _ := Check("1.4.0", r, "app"); v != TooNew {
t.Errorf("want TooNew, got %v", v)
}
// A development build reports "dev". Locking developers out of their own server would be a
// poor trade for a check that exists to prevent confusing failures.
if v, msg := Check("dev", r, "server"); v != Unknown || msg == "" {
t.Errorf("unparseable version should be Unknown with an explanation, got %v %q", v, msg)
}
}
func contains(h, n string) bool {
return len(h) >= len(n) && (h == n || len(n) == 0 || indexOf(h, n) >= 0)
}
func indexOf(h, n string) int {
for i := 0; i+len(n) <= len(h); i++ {
if h[i:i+len(n)] == n {
return i
}
}
return -1
}
+7
View File
@@ -58,6 +58,11 @@ type Config struct {
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device) UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization 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 // Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) 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.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.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.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(&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") fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
+113
View File
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/store"
)
func rangeOrDie(t *testing.T, min, max string) compat.Range {
t.Helper()
r, err := compat.ParseRange(min, max)
if err != nil {
t.Fatal(err)
}
return r
}
func TestGateRefusesOutOfRangeApps(t *testing.T) {
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
reached := false
h := s.requireCompatibleApp(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
for _, tc := range []struct {
version string
wantCode int
wantThru bool
}{
{"0.2.0", http.StatusOK, true}, // exactly the minimum is in range
{"0.9.9", http.StatusOK, true},
{"0.1.9", http.StatusUpgradeRequired, false}, // too old
{"1.0.0", http.StatusUpgradeRequired, false}, // maximum is exclusive
{"2.0.0", http.StatusUpgradeRequired, false}, // too new
{"", http.StatusOK, true}, // unknown: allowed, see below
{"dev", http.StatusOK, true}, // development build
} {
reached = false
req := httptest.NewRequest("GET", "/v1/sessions", nil)
if tc.version != "" {
req.Header.Set(AppVersionHeader, tc.version)
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != tc.wantCode || reached != tc.wantThru {
t.Errorf("app %q: got code=%d reached=%v, want code=%d reached=%v",
tc.version, rec.Code, reached, tc.wantCode, tc.wantThru)
}
}
}
// A refusal that does not say what version to install is only marginally better than a timeout.
func TestRefusalNamesTheAcceptedWindow(t *testing.T) {
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/runs", nil)
req.Header.Set(AppVersionHeader, "0.1.0")
s.requireCompatibleApp(func(http.ResponseWriter, *http.Request) {})(rec, req)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("refusal body is not JSON: %v", err)
}
for _, key := range []string{"error", "app_version", "accepts_app", "protocol_version"} {
if body[key] == nil || body[key] == "" {
t.Errorf("refusal omits %q, so the client cannot explain itself: %v", key, body)
}
}
if !strings.Contains(body["accepts_app"].(string), "0.2.0") {
t.Errorf("accepts_app should state the minimum: %v", body["accepts_app"])
}
}
// The profile is how a refused client learns which version it needs. Gating it would leave the
// user with a network error instead of an answer, which defeats the whole check.
func TestProfileIsReachableRegardlessOfVersion(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
s := &Server{
AppRange: rangeOrDie(t, "9.0.0", ""), // nothing current could satisfy this
Store: st,
}
req := httptest.NewRequest("GET", "/v1/profile", nil)
req.Header.Set(AppVersionHeader, "0.1.0")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
// The request carries no credential, so the handler answers 401 — the point is that it is
// the *handler* answering, not the version gate turning it into 426.
if rec.Code == http.StatusUpgradeRequired {
t.Fatal("the profile endpoint must never be gated on app version")
}
}
func TestZeroValueRangeFallsBackToTheBuiltInDefault(t *testing.T) {
s := &Server{} // nothing configured
got := s.appRange()
want := DefaultAppRange()
if got.Min != want.Min || got.HasMax != want.HasMax || got.Max != want.Max {
t.Fatalf("appRange() = %s, want the built-in default %s", got, want)
}
}
+100 -12
View File
@@ -24,6 +24,7 @@ import (
"strings" "strings"
"time" "time"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session" "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's own egress isn't full-MTU, client MTU results measure the
// server, not the client. // server, not the client.
ProvenGood func() (mtuOK, sysctlOK bool) 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 { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() 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("GET /v1/profile", s.profile)
mux.HandleFunc("POST /v1/sessions", s.newSession)
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession) gate := s.requireCompatibleApp
mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations) mux.HandleFunc("POST /v1/enroll", gate(s.enroll))
mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions) mux.HandleFunc("POST /v1/sessions", gate(s.newSession))
mux.HandleFunc("POST /v1/echo", s.httpEcho) mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession))
mux.HandleFunc("GET /v1/tls-reference", s.tlsReference) mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations))
mux.HandleFunc("POST /v1/runs", s.uploadRun) mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions))
mux.HandleFunc("GET /v1/runs", s.listRuns) mux.HandleFunc("POST /v1/echo", gate(s.httpEcho))
mux.HandleFunc("GET /v1/runs/{id}", s.getRun) mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference))
mux.HandleFunc("DELETE /v1/runs/{id}", s.deleteRun) 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) // TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux return mux
} }
@@ -364,7 +430,12 @@ func bearer(r *http.Request) string {
func writeJSON(w http.ResponseWriter, code int, v any) { func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code) w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v) enc := json.NewEncoder(w)
// Go escapes <, > and & by default, for JSON embedded in HTML. This is an API, and the
// escaping is actively harmful here: a refusal message reading "needs >= 0.2.0" is what
// the user ends up seeing. Nothing we emit is ever interpolated into a page.
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
} }
// enroll redeems a single-use enrollment token for a device credential (§2.1). // enroll redeems a single-use enrollment token for a device credential (§2.1).
@@ -424,6 +495,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 // 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. // accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(), "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 +653,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// maxOrEmpty renders an unbounded ceiling as "" rather than as a sentinel version, so a client
// reading the profile cannot mistake a placeholder for a real bound.
func maxOrEmpty(r compat.Range) string {
if !r.HasMax {
return ""
}
return r.Max.String()
}
+1
View File
@@ -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: // Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
//
// 0 8 t_rx_ns (server clock, process epoch) // 0 8 t_rx_ns (server clock, process epoch)
// 8 8 t_tx_ns // 8 8 t_tx_ns
// 16 16 observed source IP (v4-mapped when v4) // 16 16 observed source IP (v4-mapped when v4)
+74
View File
@@ -0,0 +1,74 @@
# Echolot website (`web/`)
Minimal single-page site for [echo-lot.app](https://echo-lot.app), served from Cloudflare
Workers. Static files in `public/` are served straight from the edge; the tiny Worker in
`src/index.js` only runs for paths that aren't files:
| Path | Behavior |
| ------------- | ------------------------------------------------------------------------ |
| `/apk` | 302 → newest `.apk` asset of the latest Gitea release (QR-code friendly) |
| `/apk.sha256` | 302 → the matching `.sha256` asset |
| `/api/latest` | JSON `{version, published_at, apk, sha256}` — the homepage's version readout |
| `/fdroid`, `/source` | 302 → the URLs configured in `wrangler.jsonc` vars |
The latest release is resolved from the Gitea API **at request time** (edge-cached 5 min), so
publishing a release — `git tag v0.2.0 && git push origin v0.2.0`, which triggers
`.gitea/workflows/release.yml` — is the only release step. The site never needs a redeploy for
a new version, and empty/unreachable values fall back to the homepage instead of 404ing.
Light/dark follows the OS (`prefers-color-scheme`), no toggle, no JS required for it. Colors
come from the branding palette (teal = instrument, single amber point = finding).
`public/assets/` (favicon, wordmark, social preview) are **copies** of `../assets/branding/`
that directory is the source of truth; re-copy after any branding change.
## Deploy
Everything is driven by [wrangler](https://developers.cloudflare.com/workers/wrangler/), config
in `wrangler.jsonc`. No build step, no node_modules to commit.
### One-time setup
1. In the Cloudflare dashboard, add **echo-lot.app** as a zone (and point the domain's
nameservers at Cloudflare). The `routes` in `wrangler.jsonc` use `custom_domain: true`, so
wrangler creates the DNS records for `echo-lot.app` and `www` automatically on first deploy —
the zone just has to exist in the same account.
2. Auth, either flavor:
- **Interactive:** `npx wrangler login` (opens the browser once, stores an OAuth token).
- **API token (also what CI uses):** dashboard → My Profile → API Tokens → create from the
**"Edit Cloudflare Workers"** template. Then:
```
$env:CLOUDFLARE_API_TOKEN = "..." # PowerShell; export ... on POSIX
$env:CLOUDFLARE_ACCOUNT_ID = "..." # dashboard → Workers & Pages, right sidebar
```
### Deploy
```
cd web
npx wrangler@4 deploy
```
That's it — uploads `src/index.js` + the `public/` assets, wires the custom domains. Useful
extras: `npx wrangler dev` (local preview at localhost:8787), `npx wrangler tail` (live logs),
`npx wrangler versions list`.
### CI deploy (Gitea Actions)
`.gitea/workflows/deploy-site.yml` runs `wrangler deploy` on every push to `main`/`master` that
touches `web/`. It stays inert until you add two repo secrets (Settings → Actions → Secrets):
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (same values as above).
Cloudflare's raw REST API (`PUT /accounts/:id/workers/scripts/...`) exists, but the assets
upload needs a manifest/session dance that wrangler already implements — use wrangler even in
automation.
## Config knobs (`wrangler.jsonc` → `vars`)
- `GITEA_REPO_API` — Gitea repo API base; releases must be publicly readable.
- `DOWNLOAD_URL` — manual `/apk` fallback while Gitea is unreachable.
- `FDROID_URL` — set when the F-Droid listing exists; until then `/fdroid` loops home.
- `SOURCE_URL` — public source mirror for the footer + `/source`.
Vars are plain (non-secret) config; change + `wrangler deploy` to apply.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

After

Width:  |  Height:  |  Size: 742 B

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

After

Width:  |  Height:  |  Size: 743 B

+156 -87
View File
@@ -5,103 +5,124 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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 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: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/"> <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> <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 { :root {
--depth-0: #0B2437; /* surface */ color-scheme: dark;
--depth-1: #092031; /* photic */ --bg-0: #0C2130; /* top of page */
--depth-2: #071A29; /* mid */ --bg-1: #071522; /* abyss — page floor, panel ground */
--depth-3: #051320; /* floor */ --tile: #0E2433; /* raised surfaces */
--foam: #DCE9F1; /* primary text */ --foam: #E8F4F2; /* primary text */
--slate: #8AA5B8; /* secondary text */ --slate: #7DA2AC; /* secondary text */
--grid: #16374E; /* hairlines, chart grid */ --caption:#5E8B96; /* mono captions, from the banner */
--ping: #FFB454; /* the one accent: sonar amber */ --grid: #10303F; /* hairlines */
--ok: #7BC98F; /* verdict green, chips only */ --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; --mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; --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; } * { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { body {
font-family: var(--sans); font-family: var(--sans);
color: var(--foam); 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; line-height: 1.6;
-webkit-font-smoothing: antialiased; -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; } 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; } .col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; }
/* Depth ruler: fixed left margin scale, desktop only. Marks are set per-section /* Graduated rule: the tick motif from the banner, vertical. Fixed left
by scroll position purely decoratively — it is a ruler, not navigation. */ margin, desktop only, purely decorative. */
.ruler { .rule {
position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem; position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem;
border-right: 1px solid var(--grid); border-right: 1px solid var(--grid);
font-family: var(--mono); font-size: .65rem; color: var(--slate);
display: none; display: none;
} }
@media (min-width: 72rem) { .ruler { display: block; } } @media (min-width: 72rem) { .rule { display: block; } }
.ruler span { .rule::after {
position: absolute; right: .5rem; transform: translateY(-50%); 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);
.ruler span::after {
content: ""; position: absolute; right: -.55rem; top: 50%;
width: .35rem; height: 1px; background: var(--slate);
} }
header.hero { padding: 4.5rem 0 3rem; } header.hero { padding: 4.5rem 0 3rem; }
.wordmark { .wordmark { display: block; height: 30px; width: auto; }
font-family: var(--mono); font-size: .8rem; letter-spacing: .35em;
text-transform: uppercase; color: var(--slate);
}
.wordmark b { color: var(--ping); font-weight: 600; }
h1 { h1 {
font-size: clamp(1.9rem, 5vw, 3rem); font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 650; letter-spacing: -.02em; line-height: 1.15; 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 { color: var(--slate); max-width: 52ch; font-size: 1.05rem; }
.hero p.lede strong { color: var(--foam); font-weight: 600; } .hero p.lede strong { color: var(--foam); font-weight: 600; }
/* Echogram: the signature. A chart-recorder trace of ping RTTs; the sweep /* Focus panel: the signature, straight from the mark. The network at rest,
line is the sounder, the profile is the "seabed" the echoes draw. */ one node under examination — teal brackets are the instrument, the amber
figure.echogram { point is the finding. */
figure.focus {
margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px; margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px;
background: background: var(--tile);
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);
position: relative; overflow: hidden; position: relative; overflow: hidden;
} }
.echogram svg { display: block; width: 100%; height: auto; } .focus svg { display: block; width: 100%; height: auto; }
.echogram figcaption { .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; 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 { @keyframes examine { 0%, 100% { opacity: .3; } 50% { opacity: .1; } }
position: absolute; top: 0; bottom: 0; width: 1px; .focus .halo { animation: examine 3.2s ease-in-out infinite; }
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%; } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sweep { animation: none; left: 62%; } .focus .halo { animation: none; }
html { scroll-behavior: auto; } html { scroll-behavior: auto; }
} }
section { padding: 3.5rem 0 0; } section { padding: 3.5rem 0 0; }
.eyebrow { .eyebrow {
font-family: var(--mono); font-size: .7rem; letter-spacing: .25em; 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; } h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; }
section > .col > p { color: var(--slate); max-width: 58ch; } section > .col > p { color: var(--slate); max-width: 58ch; }
@@ -129,21 +150,26 @@
border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem; border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem;
color: var(--slate); color: var(--slate);
} }
.tier b { color: var(--ok); font-weight: 600; } .tier b { color: var(--teal); font-weight: 600; }
/* Install */ /* 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 { .btn {
display: inline-block; padding: .7rem 1.3rem; border-radius: 4px; display: inline-block; padding: .7rem 1.3rem; border-radius: 4px;
font-weight: 600; text-decoration: none; font-size: .95rem; 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.primary:hover { filter: brightness(1.08); }
.btn.ghost { border: 1px solid var(--grid); color: var(--foam); } .btn.ghost { border: 1px solid var(--grid); color: var(--foam); }
.btn.ghost:hover { border-color: var(--slate); } .btn.ghost:hover { border-color: var(--slate); }
.note { .note {
font-size: .85rem; color: var(--slate); 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; } .checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; }
@@ -157,45 +183,51 @@
</head> </head>
<body> <body>
<div class="ruler" aria-hidden="true"> <div class="rule" aria-hidden="true"></div>
<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>
<header class="hero"> <header class="hero">
<div class="col"> <div class="col">
<p class="wordmark"><b></b> echo·lot <span aria-hidden="true">/ˈɛçolo:t/ — echo sounder</span></p> <picture>
<h1>Depth soundings for your local network.</h1> <source srcset="/assets/wordmark-on-dark.svg" media="(prefers-color-scheme: dark)">
<p class="lede">An echo sounder maps the seabed by timing returns. <strong>Echolot</strong> does the <img class="wordmark" src="/assets/wordmark-on-light.svg" alt="echolot" width="125" height="30">
same to your network: free Android diagnostics for the layer where things actually break — </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> <strong>no root required</strong>. Built for people who know what a neighbor table is.</p>
<figure class="echogram"> <figure class="focus">
<figcaption>trace · icmp.ping4 · rtt ms ↓ / t →</figcaption> <figcaption>focus · dhcp.rogue_detect · tier:app</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"> <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">
<!-- echo returns: the profile --> <!-- the network, at rest -->
<polyline fill="none" stroke="#FFB454" stroke-width="1.5" opacity=".9" <g class="rest-node">
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"/> <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"/>
<!-- second, fainter return (multipath) --> <circle cx="120" cy="120" r="3"/><circle cx="480" cy="120" r="3"/><circle cx="600" cy="120" r="3"/>
<polyline fill="none" stroke="#FFB454" stroke-width="1" opacity=".25" <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"/>
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"/> </g>
<!-- dropped probes --> <!-- one node, under examination -->
<g fill="#8AA5B8" font-family="monospace" font-size="9"> <circle class="halo" cx="240" cy="120" r="11"/>
<text x="497" y="30">×</text><text x="507" y="30">×</text> <circle class="finding" cx="240" cy="120" r="5"/>
<text x="288" y="30">▲ spike: wifi→cell handover</text> <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> </g>
</svg> </svg>
<div class="sweep" aria-hidden="true"></div>
</figure> </figure>
</div> </div>
</header> </header>
<section id="what"> <section id="what">
<div class="col"> <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> <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 <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 home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router
@@ -234,15 +266,16 @@
<div class="col"> <div class="col">
<p class="eyebrow">Install</p> <p class="eyebrow">Install</p>
<h2>Get Echolot</h2> <h2>Get Echolot</h2>
<p class="release" id="release" hidden></p>
<div class="buttons"> <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> <a class="btn ghost" href="/fdroid">F-Droid</a>
</div> </div>
<p class="note"><strong>Pre-release.</strong> The capability prober is running on real <p class="note" id="prerelease-note"><strong>Pre-release.</strong> The capability prober is
hardware; the production app is under construction. These links go live with the first running on real hardware; the production app is under construction. These links go live with
release — until then they loop back here. No mailing list, no tracker: check back, or watch the first release — until then they loop back here. No mailing list, no tracker: check back,
the <a href="/source">repository</a>.</p> 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="checksum" id="checksum">releases will ship with sha256sums + a signing key you can pin</p>
</div> </div>
</section> </section>
@@ -253,5 +286,41 @@
</div> </div>
</footer> </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> </body>
</html> </html>
+47 -14
View File
@@ -3,28 +3,32 @@
// Everything under public/ is served straight from the edge without invoking // Everything under public/ is served straight from the edge without invoking
// this Worker. The Worker exists for the short stable URLs (/apk, /fdroid, // 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 // /source) — short enough for a QR code — and for /api/latest, which the
// release asset at request time, so tagging a release in Gitea is the only // homepage uses to show the current version. Both resolve the newest release
// publish step. No site redeploy, no URL to update. // 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 = { const STATIC_ROUTES = {
"/fdroid": "FDROID_URL", "/fdroid": "FDROID_URL",
"/source": "SOURCE_URL", "/source": "SOURCE_URL",
}; };
// Resolve the newest APK from the Gitea "latest release" API. Cached at the // Fetch the Gitea "latest release" object. Cached at the edge for 5 minutes so
// edge for 5 minutes so a release becomes visible quickly, while Gitea sees // a new release becomes visible quickly, while Gitea sees at most one API hit
// at most one API hit per POP per 5 min regardless of download traffic. // per POP per 5 min regardless of traffic. Returns null on any failure —
async function latestApkUrl(env) { // callers degrade to fallbacks rather than surfacing errors.
async function latestRelease(env) {
if (!env.GITEA_REPO_API) return null; if (!env.GITEA_REPO_API) return null;
const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, { const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, {
headers: { Accept: "application/json", "User-Agent": "echolot-site" }, headers: { Accept: "application/json", "User-Agent": "echolot-site" },
cf: { cacheTtl: 300, cacheEverything: true }, cf: { cacheTtl: 300, cacheEverything: true },
}); });
if (!res.ok) return null; if (!res.ok) return null;
const rel = await res.json(); return res.json();
const apk = rel.assets?.find((a) => a.name?.endsWith(".apk")); }
return apk?.browser_download_url ?? null;
function asset(rel, suffix) {
return rel?.assets?.find((a) => a.name?.endsWith(suffix)) ?? null;
} }
function redirect(location) { 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 { export default {
async fetch(request, env) { async fetch(request, env) {
const { pathname } = new URL(request.url); const { pathname } = new URL(request.url);
const path = pathname.replace(/\/$/, ""); const path = pathname.replace(/\/$/, "");
const fallback = new URL("/#install", request.url).toString(); const fallback = new URL("/#install", request.url).toString();
if (path === "/apk" || path === "/download") { if (path === "/apk" || path === "/download" || path === "/apk.sha256") {
// Order: live Gitea release → manual override → install section. const suffix = path === "/apk.sha256" ? ".sha256" : ".apk";
let target = null; let target = null;
try { try {
target = await latestApkUrl(env); target = asset(await latestRelease(env), suffix)?.browser_download_url;
} catch { } catch {
// Gitea unreachable — fall through rather than 500 on a download link. // 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]; const varName = STATIC_ROUTES[path];
+5 -5
View File
@@ -22,14 +22,14 @@
// its route fall back to the homepage's install section, so nothing 404s // its route fall back to the homepage's install section, so nothing 404s
// before the first release exists. // before the first release exists.
"vars": { "vars": {
// Gitea repo API base, e.g. "https://git.example.net/api/v1/repos/mram/echolot". // Gitea repo API base. The repo (or at least its releases) must be
// The repo (or at least its releases) must be publicly readable. // publicly readable for /apk and /api/latest to resolve.
"GITEA_REPO_API": "", "GITEA_REPO_API": "https://git.rambossek.at/api/v1/repos/EchoLot/echolot",
// Manual override / fallback while GITEA_REPO_API is unset or unreachable. // Manual override / fallback while GITEA_REPO_API is unreachable.
"DOWNLOAD_URL": "", "DOWNLOAD_URL": "",
// F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/ // F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/
"FDROID_URL": "", "FDROID_URL": "",
// Public source URL, the footer + /source target. // Public source URL, the footer + /source target.
"SOURCE_URL": "" "SOURCE_URL": "https://git.rambossek.at/EchoLot/echolot"
} }
} }