diff --git a/CLAUDE.md b/CLAUDE.md index bf03e5b..197494b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,10 +32,25 @@ Keep prober result IDs aligned with the measurement-schema test-type registry. ## Versioning -Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps -for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the -Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release + -registry image; the server on fmr can `--self-update` from those releases. +Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental +work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are +namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → +binaries + Gitea release + registry image; the server on fmr can `--self-update` from those +releases. + +The app's version lives once, as `appVersionName` in `app/build.gradle.kts`; **`versionCode` is +derived from it** (`major*1e6 + minor*1e4 + patch*10`). Never set it by hand — a second number a +human has to remember to bump eventually disagrees with the first. + +**Versions are load-bearing** (probe-protocol.md §8): the server refuses apps outside its window +with `426`, and the app refuses servers outside its own. Two axes, kept separate: +- `protocol_version` — *can* they talk. The correctness axis; below 1.0.0 the **minor** is the + breaking axis. +- release-version window — *may* they, per policy. `[min, max)`, bounds at breaking boundaries so + a patch never strands a fleet. Client bounds: `Compat.kt`. Server: `ECHOLOT_MIN/MAX_APP_VERSION`. + +Raise a minimum only when older peers are actively harmful, and say why in the constant's comment. +`GET /v1/profile` must stay ungated — it is how a refused client learns what it needs. ## Layout @@ -130,6 +145,12 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central. Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop around this (or USB, if ever available). +- **Empty-jar race with the IDE.** VSCodium's Java/Kotlin extension runs its own Gradle daemon on + the same project; when it overlaps a CLI build, a module's `build/libs/*.jar` can end up + containing only a manifest, and Gradle then considers `jar` up-to-date. Dependent modules fail + with "Unresolved reference" on symbols that plainly exist. Fix: `rm -f /build/libs/*.jar` + and re-run the `jar` task. Suspect this whenever a reference resolves in one module but not in + its consumer. - As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works — AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with Android Studio, its `jbr` directory works as `JAVA_HOME`. diff --git a/docs/probe-protocol.md b/docs/probe-protocol.md index ea42a47..7d1c4fc 100644 --- a/docs/probe-protocol.md +++ b/docs/probe-protocol.md @@ -192,14 +192,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference. Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above. -## 8. Cross-references to the measurement schema +## 8. Version compatibility + +Both artifacts are versioned with **SemVer**: the Go server (`server-vX.Y.Z` tags) and the Android +app (`versionName`; `versionCode` is derived from it, never maintained separately). Two independent +things are checked, and conflating them is the mistake this section exists to prevent. + +### 8.1 Protocol version — *can* these builds talk? + +`protocol_version` is the version of **this document**. It is advertised in the profile +(`compat.protocol_version`) and is the correctness axis: a peer in a different breaking series +cannot be talked to, whatever its release version says. Below `1.0.0` the **minor** is the breaking +axis (SemVer §4); at and above it, the major is. A patch bump of the protocol never splits a fleet. + +### 8.2 Release-version window — *should* they, per policy? + +Each side declares the range of peer release versions it will work with, as `[min, max)` — +**minimum inclusive, maximum exclusive**, because the useful bound is always "the version that +broke it" and writing that literally is unambiguous. An empty maximum means unbounded. + +The server advertises its window and enforces it: + +```jsonc +"compat": { + "protocol_version": "1.0.0", + "schema_version": "1.0.0", + "app_min": "0.2.0", + "app_max": "1.0.0" // exclusive; "" = no upper bound +} +``` + +Operators override it with `ECHOLOT_MIN_APP_VERSION` / `ECHOLOT_MAX_APP_VERSION` (or +`--min-app-version` / `--max-app-version`). A malformed bound is **fatal at startup**, not ignored: +a typo must not silently disable a restriction the operator meant to set. + +The app sends its version on every control-plane request: + +``` +X-Echolot-App-Version: 0.2.0 +``` + +and carries its own bounds for the server (`MIN_SERVER` / `MAX_SERVER` in `Compat.kt`). It checks +the profile in **both** directions — is the server in our range, and are we in the server's — so a +mismatch is reported before a run starts rather than discovered halfway through one. + +### 8.3 Rules + +1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs. + Gating it leaves the user with a network error instead of an answer, defeating the check. +2. **Refusal is `426 Upgrade Required`**, with a body naming both versions and the accepted window: + ```json + { "error": "app 0.1.0 is older than this build supports (needs >= 0.2.0, < 1.0.0). Update the app.", + "app_version": "0.1.0", "accepts_app": ">= 0.2.0, < 1.0.0", + "server_version": "0.5.0", "protocol_version": "1.0.0" } + ``` +3. **An unparseable or absent version is `unknown`, and is allowed.** Development builds report + `dev`, and a client too old to send the header cannot be identified anyway. The check exists to + turn confusing failures into clear ones; refusing what it cannot identify does the opposite. +4. **Bounds move at breaking boundaries, not at releases.** Shipping a patch must never require + editing a range. A minimum is raised only when older peers are actually harmful — e.g. the app + requires server `>= 0.4.2` because earlier multi-homed servers sent granted traffic from an + address the session never used, which the client measured as 100 % downstream loss. A + confidently wrong measurement is worse than a refused one. + +## 9. Cross-references to the measurement schema - Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2). - `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note). - Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`. - `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`. -## 9. Open items +## 10. Open items 1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit. 2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design. diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts index 9c494df..8ebcfb0 100644 --- a/echolot-app/app/build.gradle.kts +++ b/echolot-app/app/build.gradle.kts @@ -8,6 +8,20 @@ plugins { alias(libs.plugins.kotlin.serialization) } +// The app's version is SemVer and lives here, once. versionCode is derived from it rather than +// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number +// that a human has to remember to bump is a number that eventually disagrees with the first — and +// the version is now load-bearing, since the server decides whether to serve us by it. +// +// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing +// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100. +val appVersionName = "0.2.0" + +fun versionCodeOf(semver: String): Int { + val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt) + return major * 1_000_000 + minor * 10_000 + patch * 10 +} + android { namespace = "app.echo_lot.app" compileSdk = 36 @@ -16,12 +30,15 @@ android { applicationId = "app.echo_lot.app" minSdk = 26 targetSdk = 36 - versionCode = 2 - versionName = "0.2.0" + versionCode = versionCodeOf(appVersionName) + versionName = appVersionName // Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true` // runs a measurement immediately and POSTs the report here (dev collection endpoint). buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"") buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"") + // The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's + // parser anyway — sent to servers so they can apply their compatibility window. + buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"") } buildTypes { release { isMinifyEnabled = false } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 077eb6e..b09b989 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -88,6 +88,8 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { preview = vm.uploadPreview(r.id) } } }, + onCheckServer = vm::checkServer, + serverStatus = vm.state.archiveStatus, onBack = { screen = Screen.RUN }, ) Screen.HISTORY -> HistoryScreen( diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index e25e60f..9109ae4 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -10,8 +10,10 @@ import app.echo_lot.measurement.MeasurementDocument import app.echo_lot.privacy.Anonymizer import app.echo_lot.privacy.PrivacyLevel import app.echo_lot.privacy.Salt +import app.echo_lot.protocol.Compat import app.echo_lot.protocol.ControlClient import app.echo_lot.protocol.UploadRefused +import app.echo_lot.protocol.VersionRefused import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject @@ -66,10 +68,46 @@ class RunStore(context: Context, private val settings: Settings) { data class Sent(val serverName: String, val detail: String) : UploadOutcome /** The operator's policy says no. Not retryable, and not the user's fault. */ data class Refused(val reason: String) : UploadOutcome + /** + * The two builds do not go together. Kept apart from [Refused] and [Failed] because the + * remedy is different and specific — install a particular version — and a message that + * says so is worth more than one that says "upload failed". + */ + data class Incompatible(val reason: String) : UploadOutcome data class Failed(val detail: String) : UploadOutcome data object NotConfigured : UploadOutcome } + private fun client() = ControlClient( + settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER, + ) + + /** + * Checks the configured server without uploading anything: reachable, pinned, compatible, and + * willing to accept runs. Lets the user find out in settings rather than from a failed run. + */ + fun checkServer(): String { + if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first." + return try { + val profile = client().profile(settings.serverCredential) + val compat = Compat.check(profile, BuildConfig.APP_SEMVER) + val head = "${profile.name} · server ${profile.serverVersion} · " + + "protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}" + when { + compat.message != null -> head + "\n" + compat.message + else -> { + val uploads = profile.uploads.refusalReason() + ?: "uploads accepted (min anonymization: ${profile.uploads.minAnonymization})" + head + "\nCompatible. " + uploads + } + } + } catch (e: VersionRefused) { + "This server will not serve this app: ${e.message}" + } catch (t: Throwable) { + "Could not reach the server: ${t.message ?: t.javaClass.simpleName}" + } + } + /** * Uploads one archived run to the configured server, redacting first. * @@ -81,8 +119,14 @@ class RunStore(context: Context, private val settings: Settings) { if (!settings.serverConfigured) return UploadOutcome.NotConfigured val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive") return try { - val client = ControlClient(settings.serverUrl, setOf(settings.serverPin)) + val client = client() val profile = client.profile(settings.serverCredential) + + // Compatibility before policy: an incompatible server may well advertise an upload + // policy it would never actually apply to us. + val compat = Compat.check(profile, BuildConfig.APP_SEMVER) + if (!compat.usable) return UploadOutcome.Incompatible(compat.message ?: "incompatible versions") + profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) } val level = PrivacyLevel.max( @@ -93,6 +137,8 @@ class RunStore(context: Context, private val settings: Settings) { val reply = client.uploadRun(settings.serverCredential, body) archive.markUploaded(runId, profile.name) UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}") + } catch (e: VersionRefused) { + UploadOutcome.Incompatible(e.message ?: "the server refused this app's version") } catch (e: UploadRefused) { UploadOutcome.Refused(e.message ?: "refused by the server") } catch (t: Throwable) { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 2109af7..c5ac6ed 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -144,6 +144,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { private fun describe(o: RunStore.UploadOutcome): String = when (o) { is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})" is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}" + is RunStore.UploadOutcome.Incompatible -> "version mismatch: ${o.reason}" is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}" RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded" } @@ -195,6 +196,14 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { fun archivedBytes(): Long = store.totalBytes() + /** Settings-screen action: report what the configured server is and whether we can use it. */ + fun checkServer() { + viewModelScope.launch { + state = state.copy(archiveStatus = "checking server …") + state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.checkServer() }) + } + } + /** Re-applies retention after the user changes the limits. */ fun applyRetention() { viewModelScope.launch { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt index 4956ec7..ce83c4c 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt @@ -46,6 +46,8 @@ fun SettingsScreen( onApplyRetention: () -> Unit, onDeleteAll: () -> Unit, onPreviewUpload: () -> Unit, + onCheckServer: () -> Unit, + serverStatus: String?, onBack: () -> Unit, ) { // SharedPreferences is not observable, so mirror each value into Compose state and write @@ -166,9 +168,23 @@ fun SettingsScreen( textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.fillMaxWidth(), ) + Row(verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onCheckServer) { Text("Check server") } + Text( + " " + if (settings.serverConfigured) "Configured." + else "Uploads stay off until all three fields are set.", + style = MaterialTheme.typography.bodySmall, + ) + } + // Version compatibility is checked here rather than discovered mid-run: a server + // that will refuse this build should say so before a measurement is wasted. + serverStatus?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } Text( - if (settings.serverConfigured) "Server configured." - else "Uploads stay off until all three fields are set.", + "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, ) } diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Compat.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Compat.kt new file mode 100644 index 0000000..3c7174d --- /dev/null +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Compat.kt @@ -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 { + + 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) + } +} diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt index 1b3c1fc..b50b96c 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt @@ -7,6 +7,16 @@ import kotlinx.serialization.json.Json import java.net.URL import javax.net.ssl.HttpsURLConnection +/** The server declined to store this run, per the operator's policy. Not a transport failure. */ +class UploadRefused(message: String) : Exception(message) + +/** + * The server refused this app's version. Distinct from a transport failure and from an auth + * failure: nothing about the request was wrong, the two builds simply do not go together, and the + * message says which versions do. + */ +class VersionRefused(message: String) : Exception(message) + /** * The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over * SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike @@ -16,11 +26,14 @@ import javax.net.ssl.HttpsURLConnection * * @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443" * @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix) + * @param appVersion this build's SemVer, sent on every request so the server can refuse a build + * it cannot serve *before* a measurement half-runs (BuildConfig.VERSION_NAME). */ -/** The server declined to store this run, per the operator's policy. Not a transport failure. */ -class UploadRefused(message: String) : Exception(message) - -class ControlClient(private val controlUrl: String, pins: Set) { +class ControlClient( + private val controlUrl: String, + pins: Set, + private val appVersion: String = "", +) { private val json = Json { ignoreUnknownKeys = true } private val socketFactory = Pinning.sslContext(pins).socketFactory @@ -33,12 +46,39 @@ class ControlClient(private val controlUrl: String, pins: Set) { conn.connectTimeout = 10_000 conn.readTimeout = 10_000 credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + if (appVersion.isNotBlank()) conn.setRequestProperty(Compat.APP_VERSION_HEADER, appVersion) return conn } + /** + * 426 is the server saying "your version, not your request". Raised as a distinct exception + * from every call site so callers never report it as a network error — the whole value of the + * check is that the failure is legible. + */ + private fun checkVersion(conn: HttpsURLConnection, body: String) { + if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200)) + } + + /** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */ + private fun extractError(body: String): String? = + Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body) + ?.groupValues?.get(1) + ?.replace("\\\"", "\"") + ?.replace("\\n", "\n") + ?.replace("\\\\", "\\") + + /** + * Reads the response body, and turns a 426 into [VersionRefused] first. + * + * Every call goes through here, so the version check cannot be forgotten at a new call site — + * the alternative (a check per method) is exactly the kind of thing that gets missed once and + * then reports "upload failed: 426" to a user for a year. + */ private fun body(conn: HttpsURLConnection): String { val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream - return stream?.bufferedReader()?.use { it.readText() } ?: "" + val text = stream?.bufferedReader()?.use { it.readText() } ?: "" + checkVersion(conn, text) + return text } private fun writeJson(conn: HttpsURLConnection, payload: String) { @@ -66,21 +106,24 @@ class ControlClient(private val controlUrl: String, pins: Set) { val conn = open("/v1/enroll", "POST", null) conn.setRequestProperty("Authorization", "Bearer $token") writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}") - check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" } - return json.decodeFromString(EnrollResponse.serializer(), body(conn)) + val text = body(conn) // reads and raises VersionRefused on 426 + check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} $text" } + return json.decodeFromString(EnrollResponse.serializer(), text) } fun profile(credential: String): Profile { val conn = open("/v1/profile", "GET", credential) - check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" } - return json.decodeFromString(Profile.serializer(), body(conn)) + val text = body(conn) + check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} $text" } + return json.decodeFromString(Profile.serializer(), text) } fun createSession(credential: String, target: String): SessionResponse { val conn = open("/v1/sessions", "POST", credential) writeJson(conn, """{"target":${jstr(target)}}""") - check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" } - return json.decodeFromString(SessionResponse.serializer(), body(conn)) + val text = body(conn) + check(conn.responseCode == 201) { "session failed: ${conn.responseCode} $text" } + return json.decodeFromString(SessionResponse.serializer(), text) } /** @@ -111,7 +154,7 @@ class ControlClient(private val controlUrl: String, pins: Set) { val body = body(conn) when (conn.responseCode) { in 200..299 -> return body - 403 -> throw UploadRefused(body) + 403 -> throw UploadRefused(extractError(body) ?: body.take(200)) 413 -> throw UploadRefused("run is larger than this server accepts: $body") else -> error("upload failed: ${conn.responseCode} $body") } @@ -120,14 +163,16 @@ class ControlClient(private val controlUrl: String, pins: Set) { /** Lists this device's runs stored on the server. */ fun listRuns(credential: String): String { val conn = open("/v1/runs", "GET", credential) - check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" } - return body(conn) + val text = body(conn) + check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode} $text" } + return text } fun getRun(credential: String, runId: String): String { val conn = open("/v1/runs/$runId", "GET", credential) - check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" } - return body(conn) + val text = body(conn) + check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" } + return text } fun deleteRun(credential: String, runId: String) { @@ -136,8 +181,9 @@ class ControlClient(private val controlUrl: String, pins: Set) { fun observations(credential: String, sessionId: String): String { val conn = open("/v1/sessions/$sessionId/observations", "GET", credential) - check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" } - return body(conn) + val text = body(conn) + check(conn.responseCode == 200) { "observations failed: ${conn.responseCode} $text" } + return text } fun deleteSession(credential: String, sessionId: String) { diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt index dd9d492..256246a 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/Model.kt @@ -56,6 +56,16 @@ data class UploadPolicy( } } +/** The server's declaration of what it speaks and which app versions it will serve. */ +@Serializable +data class CompatInfo( + @SerialName("protocol_version") val protocolVersion: String = "", + @SerialName("schema_version") val schemaVersion: String = "", + @SerialName("app_min") val appMin: String = "", + /** Exclusive; empty means the server sets no upper bound. */ + @SerialName("app_max") val appMax: String = "", +) + @Serializable data class Profile( @SerialName("profile_version") val profileVersion: Int = 0, @@ -67,6 +77,7 @@ data class Profile( @SerialName("server_selftest") val serverSelftest: SelfTest? = null, val pins: List = emptyList(), val uploads: UploadPolicy = UploadPolicy(), + val compat: CompatInfo = CompatInfo(), ) { fun supports(capability: String) = capability in capabilities } diff --git a/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/CompatTest.kt b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/CompatTest.kt new file mode 100644 index 0000000..935aee1 --- /dev/null +++ b/echolot-app/core-protocol/src/test/kotlin/app/echo_lot/protocol/CompatTest.kt @@ -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") + } +} diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index e5e0b33..edbd0c3 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -36,6 +36,7 @@ import ( "time" "echo-lot.app/server/internal/canarydns" + "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/config" "echo-lot.app/server/internal/control" "echo-lot.app/server/internal/dataplane" @@ -131,6 +132,15 @@ func serve(cfg *config.Config) error { "retention_days", cfg.UploadRetentionDays, "max_runs_per_device", cfg.UploadMaxRuns) } + // A malformed window is fatal rather than ignored: an operator who set a restriction must + // not end up running without one because of a typo. + appRange, err := compat.ParseRange(cfg.MinAppVersion, cfg.MaxAppVersion) + if err != nil { + return fmt.Errorf("app version window: %w", err) + } + slog.Info("client compatibility", "accepts_app", appRange.String(), + "protocol", control.ProtocolVersion, "schema", control.SchemaVersion) + ctl := &control.Server{ Store: st, Sessions: sessions, Name: cfg.Name, UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), @@ -140,6 +150,7 @@ func serve(cfg *config.Config) error { BigSend: dp.BigSend, TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) }, Runs: runStore, + AppRange: appRange, } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/server/internal/compat/compat.go b/server/internal/compat/compat.go new file mode 100644 index 0000000..947dbfa --- /dev/null +++ b/server/internal/compat/compat.go @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package compat decides whether two Echolot builds should talk to each other. +// +// Versions are SemVer. What is actually being checked, though, is not "is this release recent" +// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for +// that, and the proxy only holds because we bump the breaking axis when the contract changes. +// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must +// never strand a fleet, and the range must not need editing to ship a bugfix. +// +// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot +// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its +// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says. +package compat + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of +// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0". +type Version struct { + Major, Minor, Patch int + Pre string +} + +// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because +// those are the forms that actually reach this code: a tag, a --version output, and an HTTP +// header written by three different pieces of software. +func Parse(s string) (Version, bool) { + s = strings.TrimSpace(s) + // Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having + // no digits so a pre-release identifier that happens to contain a "v" is left alone. + if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) && + s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") { + s = s[i+1:] + } + if plus := strings.IndexByte(s, '+'); plus >= 0 { + s = s[:plus] + } + var pre string + if dash := strings.IndexByte(s, '-'); dash >= 0 { + pre, s = s[dash+1:], s[:dash] + } + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, false + } + out := Version{Pre: pre} + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return Version{}, false + } + switch i { + case 0: + out.Major = n + case 1: + out.Minor = n + case 2: + out.Patch = n + } + } + return out, true +} + +func (v Version) String() string { + s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Pre != "" { + s += "-" + v.Pre + } + return s +} + +// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11); +// two pre-releases compare lexically, which is close enough for the identifiers we use. +func (v Version) Compare(o Version) int { + for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} { + if d != 0 { + return sign(d) + } + } + switch { + case v.Pre == o.Pre: + return 0 + case v.Pre == "": + return 1 + case o.Pre == "": + return -1 + case v.Pre < o.Pre: + return -1 + } + return 1 +} + +func (v Version) Less(o Version) bool { return v.Compare(o) < 0 } + +// NextBreaking is the first version that may break compatibility with v. +// +// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's +// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a +// 0.5 server accept a 0.4 app that cannot speak to it. +func (v Version) NextBreaking() Version { + if v.Major == 0 { + return Version{Major: 0, Minor: v.Minor + 1} + } + return Version{Major: v.Major + 1} +} + +// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded. +// +// Exclusive on the upper end because the useful bound is always "the version that broke it", +// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not. +type Range struct { + Min Version + Max Version + HasMax bool +} + +func (r Range) Contains(v Version) bool { + if v.Less(r.Min) { + return false + } + if r.HasMax && !v.Less(r.Max) { + return false + } + return true +} + +func (r Range) String() string { + if !r.HasMax { + return ">= " + r.Min.String() + } + return ">= " + r.Min.String() + ", < " + r.Max.String() +} + +// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is +// an error rather than a silently ignored one: a typo in an operator's config must not quietly +// turn a restriction off. +func ParseRange(min, max string) (Range, error) { + lo, ok := Parse(min) + if !ok { + return Range{}, fmt.Errorf("bad minimum version %q", min) + } + if strings.TrimSpace(max) == "" { + return Range{Min: lo}, nil + } + hi, ok := Parse(max) + if !ok { + return Range{}, fmt.Errorf("bad maximum version %q", max) + } + if hi.Less(lo) || hi.Compare(lo) == 0 { + return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min) + } + return Range{Min: lo, Max: hi, HasMax: true}, nil +} + +// Verdict is the outcome of a compatibility check. +type Verdict int + +const ( + OK Verdict = iota + TooOld + TooNew + // Unknown means the peer did not say, or said something unparseable — a development build, + // or a client too old to send its version at all. + Unknown +) + +// Check reports whether peer falls in r, and explains the answer in words meant for a person. +// The message is deliberately actionable: it names both versions and what to do about it, since +// it is the only thing the user on the other end will see. +func Check(peer string, r Range, peerName string) (Verdict, string) { + v, ok := Parse(peer) + if !ok { + return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer) + } + switch { + case v.Less(r.Min): + return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.", + peerName, v, r, peerName) + case r.HasMax && !v.Less(r.Max): + return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.", + peerName, v, r, peerName) + } + return OK, "" +} + +func sign(d int) int { + if d < 0 { + return -1 + } + if d > 0 { + return 1 + } + return 0 +} diff --git a/server/internal/compat/compat_test.go b/server/internal/compat/compat_test.go new file mode 100644 index 0000000..d47ae67 --- /dev/null +++ b/server/internal/compat/compat_test.go @@ -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 +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 9052a89..ab3fbc4 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -58,6 +58,11 @@ type Config struct { UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device) UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization + // Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The + // defaults sit at breaking boundaries, so shipping a patch never requires changing them. + MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version + MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive) + // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } @@ -106,6 +111,8 @@ func Load(args []string) (*Config, *Actions, error) { fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables") fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables") fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict") + fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") + fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)") fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit") diff --git a/server/internal/control/compat_gate_test.go b/server/internal/control/compat_gate_test.go new file mode 100644 index 0000000..28bf8a2 --- /dev/null +++ b/server/internal/control/compat_gate_test.go @@ -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) + } +} diff --git a/server/internal/control/control.go b/server/internal/control/control.go index e74540d..c2d8743 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -24,6 +24,7 @@ import ( "strings" "time" + "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/session" @@ -70,22 +71,87 @@ type Server struct { // server's own egress isn't full-MTU, client MTU results measure the // server, not the client. ProvenGood func() (mtuOK, sysctlOK bool) + + // AppRange is the app-version window this server will serve. Zero value means the built-in + // default (see DefaultAppRange). + AppRange compat.Range +} + +// AppVersionHeader is how a client states its version. A client too old to send it is treated as +// unknown rather than refused: the check exists to turn confusing failures into clear ones, and +// refusing something we cannot identify achieves the opposite. +const AppVersionHeader = "X-Echolot-App-Version" + +// ProtocolVersion is the wire contract (probe-protocol.md) this build implements. It is what the +// version window is really about; the release version is only a proxy for it. +const ProtocolVersion = "1.0.0" + +// SchemaVersion is the measurement-document format this server can store. +const SchemaVersion = "1.0.0" + +// DefaultAppRange: everything from the first app that speaks this protocol up to — but not +// including — the next breaking series. Bounds sit at breaking boundaries so shipping a patch +// never requires touching this. +func DefaultAppRange() compat.Range { + r, err := compat.ParseRange("0.2.0", "1.0.0") + if err != nil { + panic("built-in app range is malformed: " + err.Error()) + } + return r +} + +func (s *Server) appRange() compat.Range { + if s.AppRange.Min == (compat.Version{}) && !s.AppRange.HasMax { + return DefaultAppRange() + } + return s.AppRange +} + +// requireCompatibleApp wraps a handler with the version window. +// +// Deliberately NOT applied to GET /v1/profile: that is where a client learns which version it +// should be. Gating it would leave a refused client with nothing to show its user but a timeout, +// which is precisely the confusion this check exists to remove. +func (s *Server) requireCompatibleApp(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + verdict, msg := compat.Check(r.Header.Get(AppVersionHeader), s.appRange(), "app") + switch verdict { + case compat.TooOld, compat.TooNew: + slog.Info("refused incompatible app", "app_version", r.Header.Get(AppVersionHeader), + "accepts", s.appRange().String(), "path", r.URL.Path) + // 426 says exactly this and nothing else; the body carries the window so the app can + // show the user the number to reach, not just that it failed. + writeJSON(w, http.StatusUpgradeRequired, map[string]any{ + "error": msg, + "app_version": r.Header.Get(AppVersionHeader), + "accepts_app": s.appRange().String(), + "server_version": Version, + "protocol_version": ProtocolVersion, + }) + return + } + next(w, r) + } } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("POST /v1/enroll", s.enroll) + // Always reachable, whatever the version window says: this is how a client discovers the + // window it has to satisfy. mux.HandleFunc("GET /v1/profile", s.profile) - mux.HandleFunc("POST /v1/sessions", s.newSession) - mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession) - mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations) - mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions) - mux.HandleFunc("POST /v1/echo", s.httpEcho) - mux.HandleFunc("GET /v1/tls-reference", s.tlsReference) - mux.HandleFunc("POST /v1/runs", s.uploadRun) - mux.HandleFunc("GET /v1/runs", s.listRuns) - mux.HandleFunc("GET /v1/runs/{id}", s.getRun) - mux.HandleFunc("DELETE /v1/runs/{id}", s.deleteRun) + + gate := s.requireCompatibleApp + mux.HandleFunc("POST /v1/enroll", gate(s.enroll)) + mux.HandleFunc("POST /v1/sessions", gate(s.newSession)) + mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession)) + mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations)) + mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions)) + mux.HandleFunc("POST /v1/echo", gate(s.httpEcho)) + mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference)) + mux.HandleFunc("POST /v1/runs", gate(s.uploadRun)) + mux.HandleFunc("GET /v1/runs", gate(s.listRuns)) + mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun)) + mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun)) // TODO(spec §5): frag_send, throughput (both build on the same grant machinery) return mux } @@ -424,6 +490,14 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) { // The app needs the upload rules before it offers the switch: whether uploads are // accepted at all, and how much identifying detail it must strip first. "uploads": s.uploadPolicy(), + // What this build speaks, and which app versions it will serve. A client checks the + // server side of the same question against its own bounds. + "compat": map[string]any{ + "protocol_version": ProtocolVersion, + "schema_version": SchemaVersion, + "app_min": s.appRange().Min.String(), + "app_max": maxOrEmpty(s.appRange()), + }, }) } @@ -574,3 +648,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// maxOrEmpty renders an unbounded ceiling as "" rather than as a sentinel version, so a client +// reading the profile cannot mistake a placeholder for a real bound. +func maxOrEmpty(r compat.Range) string { + if !r.HasMax { + return "" + } + return r.Max.String() +} diff --git a/server/internal/dataplane/udp.go b/server/internal/dataplane/udp.go index 2eba9c0..4bfed45 100644 --- a/server/internal/dataplane/udp.go +++ b/server/internal/dataplane/udp.go @@ -177,13 +177,14 @@ func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.S } // Observation block (spec §3.3), fixed 40 bytes appended to the RESP header: -// 0 8 t_rx_ns (server clock, process epoch) -// 8 8 t_tx_ns -// 16 16 observed source IP (v4-mapped when v4) -// 32 2 observed source port -// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs) -// 35 1 received DSCP/ECN byte (0xFF = not observed) -// 36 4 received size +// +// 0 8 t_rx_ns (server clock, process epoch) +// 8 8 t_tx_ns +// 16 16 observed source IP (v4-mapped when v4) +// 32 2 observed source port +// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs) +// 35 1 received DSCP/ECN byte (0xFF = not observed) +// 36 4 received size func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte { b := make([]byte, 40) binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))