compat: SemVer version windows between app and server
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -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
|
||||||
|
|
||||||
@@ -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
|
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`.
|
||||||
|
|||||||
+65
-2
@@ -192,14 +192,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference.
|
|||||||
|
|
||||||
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.
|
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.
|
||||||
|
|||||||
@@ -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,9 +168,23 @@ 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(
|
||||||
|
" " + 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(
|
Text(
|
||||||
if (settings.serverConfigured) "Server configured."
|
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
|
||||||
else "Uploads stay off until all three fields are set.",
|
"${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +
|
||||||
|
"${app.echo_lot.protocol.Compat.serverRange}.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version compatibility, the client side of the same question the server asks about us.
|
||||||
|
*
|
||||||
|
* Versions are SemVer, but what is really being checked is whether the peer speaks a wire protocol
|
||||||
|
* and schema this build understands; the version is a proxy, and it only works because the
|
||||||
|
* breaking axis is bumped when the contract changes. Bounds therefore sit at breaking boundaries
|
||||||
|
* rather than at individual releases — a server patch release must never make the app refuse to
|
||||||
|
* talk to it.
|
||||||
|
*
|
||||||
|
* Mirrors `server/internal/compat`. Two implementations of one rule is a duplication worth
|
||||||
|
* accepting: each side must be able to state and enforce its own limits without asking the other,
|
||||||
|
* which is the entire point of a compatibility check.
|
||||||
|
*/
|
||||||
|
data class SemVer(
|
||||||
|
val major: Int,
|
||||||
|
val minor: Int,
|
||||||
|
val patch: Int,
|
||||||
|
val pre: String = "",
|
||||||
|
) : Comparable<SemVer> {
|
||||||
|
|
||||||
|
override fun compareTo(other: SemVer): Int {
|
||||||
|
(major - other.major).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||||
|
(minor - other.minor).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||||
|
(patch - other.patch).let { if (it != 0) return it.coerceIn(-1, 1) }
|
||||||
|
// A pre-release sorts below the same version without one (SemVer §11).
|
||||||
|
return when {
|
||||||
|
pre == other.pre -> 0
|
||||||
|
pre.isEmpty() -> 1
|
||||||
|
other.pre.isEmpty() -> -1
|
||||||
|
else -> pre.compareTo(other.pre).coerceIn(-1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "$major.$minor.$patch" + if (pre.isEmpty()) "" else "-$pre"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first version that may break compatibility with this one. Below 1.0.0 the minor is the
|
||||||
|
* breaking axis (SemVer §4), so 0.4.2's next break is 0.5.0 — treating it as 1.0.0 would let
|
||||||
|
* this build accept a peer it cannot actually talk to.
|
||||||
|
*/
|
||||||
|
fun nextBreaking(): SemVer =
|
||||||
|
if (major == 0) SemVer(0, minor + 1, 0) else SemVer(major + 1, 0, 0)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Accepts "1.2.3", "v1.2.3" and namespaced tags like "server-v1.2.3". Null if unusable. */
|
||||||
|
fun parse(raw: String?): SemVer? {
|
||||||
|
var s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
// Strip a tag prefix ending in "v", guarded on the prefix having no digits so a
|
||||||
|
// pre-release identifier containing a "v" is left alone.
|
||||||
|
val v = s.lastIndexOf('v')
|
||||||
|
if (v >= 0 && v + 1 < s.length && s[v + 1].isDigit() && s.take(v).none { it.isDigit() }) {
|
||||||
|
s = s.substring(v + 1)
|
||||||
|
}
|
||||||
|
s = s.substringBefore('+')
|
||||||
|
val pre = s.substringAfter('-', "")
|
||||||
|
val core = s.substringBefore('-')
|
||||||
|
val parts = core.split(".")
|
||||||
|
if (parts.size != 3) return null
|
||||||
|
val nums = parts.map { it.toIntOrNull() ?: return null }
|
||||||
|
if (nums.any { it < 0 }) return null
|
||||||
|
return SemVer(nums[0], nums[1], nums[2], pre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [min, max): minimum inclusive, maximum exclusive. A null max is unbounded. */
|
||||||
|
data class VersionRange(val min: SemVer, val max: SemVer? = null) {
|
||||||
|
operator fun contains(v: SemVer): Boolean = v >= min && (max == null || v < max)
|
||||||
|
override fun toString(): String = ">= $min" + (max?.let { ", < $it" } ?: "")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(min: String, max: String?): VersionRange {
|
||||||
|
val lo = requireNotNull(SemVer.parse(min)) { "bad minimum version: $min" }
|
||||||
|
val hi = max?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
requireNotNull(SemVer.parse(it)) { "bad maximum version: $it" }
|
||||||
|
}
|
||||||
|
require(hi == null || hi > lo) { "maximum $max is not above minimum $min" }
|
||||||
|
return VersionRange(lo, hi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this build of the app requires of a server, and what it tells servers about itself.
|
||||||
|
*
|
||||||
|
* Two separate questions, deliberately not conflated:
|
||||||
|
*
|
||||||
|
* - **Protocol version** — can these builds talk at all? This is the correctness axis, and its
|
||||||
|
* breaking boundary is enforced strictly.
|
||||||
|
* - **Release-version window** — should they, per policy? A coarse safety net over the peer's
|
||||||
|
* SemVer, with bounds at breaking boundaries so a patch release never strands anyone. Both
|
||||||
|
* sides publish their own, and the operator can tighten the server's.
|
||||||
|
*
|
||||||
|
* `MIN_SERVER` is 0.4.2 for a concrete reason, not caution: below it a multi-homed server sent
|
||||||
|
* granted traffic from an address the session never used, so every downstream packet was dropped
|
||||||
|
* in transit and reported as 100 % downstream loss. A confidently wrong measurement is worse than
|
||||||
|
* a refused one, so talking to those builds is not something to allow "just in case".
|
||||||
|
*/
|
||||||
|
object Compat {
|
||||||
|
/** Header the app sets on every control-plane request. */
|
||||||
|
const val APP_VERSION_HEADER = "X-Echolot-App-Version"
|
||||||
|
|
||||||
|
/** The wire contract (probe-protocol.md) this build implements. */
|
||||||
|
const val PROTOCOL_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
const val MIN_SERVER = "0.4.2"
|
||||||
|
|
||||||
|
/** Exclusive. The next breaking series is refused until this app is taught about it. */
|
||||||
|
const val MAX_SERVER = "1.0.0"
|
||||||
|
|
||||||
|
val serverRange: VersionRange = VersionRange.of(MIN_SERVER, MAX_SERVER)
|
||||||
|
|
||||||
|
enum class Verdict { OK, PROTOCOL_MISMATCH, SERVER_TOO_OLD, SERVER_TOO_NEW, APP_REFUSED, UNKNOWN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of checking a server, with a message written for the person holding the phone.
|
||||||
|
* [usable] is what callers branch on; [message] is what they show.
|
||||||
|
*/
|
||||||
|
data class Result(val verdict: Verdict, val message: String?) {
|
||||||
|
val usable: Boolean get() = verdict == Verdict.OK || verdict == Verdict.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a profile both ways: is the server within our range, and are we within the server's.
|
||||||
|
*
|
||||||
|
* Asking both is the point of advertising the window in the profile. Discovering that the
|
||||||
|
* server will refuse us only when a measurement fails halfway through is a much worse
|
||||||
|
* experience than being told before the run starts.
|
||||||
|
*/
|
||||||
|
fun check(profile: Profile, appVersion: String): Result {
|
||||||
|
// The protocol version is the axis that decides whether these two builds *can* talk;
|
||||||
|
// the release-version window below is the operator's policy about whether they *may*.
|
||||||
|
// Checking the real thing first means a mismatch is reported as what it is.
|
||||||
|
val ours = SemVer.parse(PROTOCOL_VERSION)!!
|
||||||
|
val theirs = SemVer.parse(profile.compat.protocolVersion)
|
||||||
|
if (theirs != null && theirs >= ours.nextBreaking()) {
|
||||||
|
return Result(
|
||||||
|
Verdict.PROTOCOL_MISMATCH,
|
||||||
|
"This server speaks probe protocol ${profile.compat.protocolVersion}; this app " +
|
||||||
|
"speaks $PROTOCOL_VERSION and does not understand that revision. Update the app.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (theirs != null && ours >= theirs.nextBreaking()) {
|
||||||
|
return Result(
|
||||||
|
Verdict.PROTOCOL_MISMATCH,
|
||||||
|
"This server speaks probe protocol ${profile.compat.protocolVersion}, which this " +
|
||||||
|
"app ($PROTOCOL_VERSION) has moved past. Update the server.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val server = SemVer.parse(profile.serverVersion)
|
||||||
|
?: return Result(
|
||||||
|
Verdict.UNKNOWN,
|
||||||
|
"Server did not report a usable version (\"${profile.serverVersion}\") — " +
|
||||||
|
"continuing without a compatibility check.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (server < serverRange.min) {
|
||||||
|
return Result(
|
||||||
|
Verdict.SERVER_TOO_OLD,
|
||||||
|
"This server runs $server; Echolot needs $serverRange. " +
|
||||||
|
"Measurements against older servers can be wrong rather than merely missing, " +
|
||||||
|
"so update the server.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (serverRange.max != null && server >= serverRange.max) {
|
||||||
|
return Result(
|
||||||
|
Verdict.SERVER_TOO_NEW,
|
||||||
|
"This server runs $server, which is newer than this app understands " +
|
||||||
|
"($serverRange). Update the app.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the server's own view of us.
|
||||||
|
val app = SemVer.parse(appVersion)
|
||||||
|
val serverWantsMin = SemVer.parse(profile.compat.appMin)
|
||||||
|
val serverWantsMax = SemVer.parse(profile.compat.appMax)
|
||||||
|
if (app != null && serverWantsMin != null) {
|
||||||
|
if (app < serverWantsMin) {
|
||||||
|
return Result(
|
||||||
|
Verdict.APP_REFUSED,
|
||||||
|
"This server only accepts Echolot $serverWantsMin or newer; this app is " +
|
||||||
|
"$app. Update the app.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (serverWantsMax != null && app >= serverWantsMax) {
|
||||||
|
return Result(
|
||||||
|
Verdict.APP_REFUSED,
|
||||||
|
"This server refuses Echolot $serverWantsMax and newer; this app is $app. " +
|
||||||
|
"Use an older app, or a server that has caught up.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result(Verdict.OK, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,16 @@ import kotlinx.serialization.json.Json
|
|||||||
import java.net.URL
|
import 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 +26,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 +46,39 @@ 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 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 {
|
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 +106,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 +154,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 +163,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 +181,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client half of the compatibility rule. Deliberately mirrors `compat_test.go`: the two
|
||||||
|
* implementations must agree on where the boundaries are, or one side refuses a peer the other
|
||||||
|
* accepts and the disagreement surfaces as an inexplicable failure in the field.
|
||||||
|
*/
|
||||||
|
class CompatTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesTheFormsThatActuallyReachUs() {
|
||||||
|
assertEquals(SemVer(1, 2, 3), SemVer.parse("1.2.3"))
|
||||||
|
assertEquals(SemVer(1, 2, 3), SemVer.parse("v1.2.3"))
|
||||||
|
assertEquals(SemVer(0, 4, 2), SemVer.parse("server-v0.4.2"))
|
||||||
|
assertEquals(SemVer(0, 2, 0), SemVer.parse(" 0.2.0 "))
|
||||||
|
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1"))
|
||||||
|
assertEquals(SemVer(1, 0, 0), SemVer.parse("1.0.0+build.7"))
|
||||||
|
assertEquals(SemVer(1, 0, 0, "rc1"), SemVer.parse("1.0.0-rc1+meta"))
|
||||||
|
// A pre-release identifier containing a "v" is not a tag prefix.
|
||||||
|
assertEquals(SemVer(1, 2, 3, "rcv1"), SemVer.parse("1.2.3-rcv1"))
|
||||||
|
|
||||||
|
for (bad in listOf("", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta", null)) {
|
||||||
|
assertNull(SemVer.parse(bad), "should not parse: $bad")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ordersPreReleasesBelowTheirRelease() {
|
||||||
|
fun lt(a: String, b: String) {
|
||||||
|
val x = assertNotNull(SemVer.parse(a))
|
||||||
|
val y = assertNotNull(SemVer.parse(b))
|
||||||
|
assertTrue(x < y, "$a should sort below $b")
|
||||||
|
assertTrue(y > x)
|
||||||
|
}
|
||||||
|
lt("0.9.9", "1.0.0")
|
||||||
|
lt("1.0.0", "1.0.1")
|
||||||
|
lt("1.0.0", "1.1.0")
|
||||||
|
lt("1.0.0-rc1", "1.0.0")
|
||||||
|
lt("1.0.0-rc1", "1.0.0-rc2")
|
||||||
|
assertEquals(0, SemVer.parse("1.2.3")!!.compareTo(SemVer.parse("v1.2.3")!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below 1.0.0 the minor is the breaking axis. Must match Go's NextBreaking exactly.
|
||||||
|
@Test
|
||||||
|
fun nextBreakingUsesTheMinorBelowOne() {
|
||||||
|
assertEquals("0.5.0", SemVer.parse("0.4.2")!!.nextBreaking().toString())
|
||||||
|
assertEquals("0.1.0", SemVer.parse("0.0.9")!!.nextBreaking().toString())
|
||||||
|
assertEquals("2.0.0", SemVer.parse("1.2.3")!!.nextBreaking().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangeIsMinInclusiveMaxExclusive() {
|
||||||
|
val r = VersionRange.of("0.2.0", "1.0.0")
|
||||||
|
for (s in listOf("0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1")) {
|
||||||
|
assertTrue(SemVer.parse(s)!! in r, "$s should be inside $r")
|
||||||
|
}
|
||||||
|
for (s in listOf("0.1.9", "1.0.0", "1.0.1", "2.0.0")) {
|
||||||
|
assertTrue(SemVer.parse(s)!! !in r, "$s should be outside $r")
|
||||||
|
}
|
||||||
|
assertTrue(SemVer.parse("99.0.0")!! in VersionRange.of("0.2.0", null), "empty max is unbounded")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun profile(serverVersion: String, appMin: String = "0.2.0", appMax: String = "1.0.0") =
|
||||||
|
Profile(
|
||||||
|
serverVersion = serverVersion,
|
||||||
|
compat = CompatInfo(protocolVersion = "1.0.0", appMin = appMin, appMax = appMax),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsAServerInsideTheWindow() {
|
||||||
|
val r = Compat.check(profile("0.4.2"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.OK, r.verdict)
|
||||||
|
assertTrue(r.usable)
|
||||||
|
assertNull(r.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.4.2 is the minimum for a concrete reason: older multi-homed servers mis-address granted
|
||||||
|
// sends and the client reports 100% downstream loss that never happened.
|
||||||
|
@Test
|
||||||
|
fun refusesAServerBelowTheMinimum() {
|
||||||
|
val r = Compat.check(profile("0.4.1"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.SERVER_TOO_OLD, r.verdict)
|
||||||
|
assertTrue(!r.usable)
|
||||||
|
assertTrue(r.message!!.contains("0.4.1") && r.message!!.contains("0.4.2"),
|
||||||
|
"the message must name both versions: ${r.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun refusesAServerFromANewerBreakingSeries() {
|
||||||
|
val r = Compat.check(profile("1.0.0"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.SERVER_TOO_NEW, r.verdict)
|
||||||
|
assertTrue(r.message!!.contains("Update the app"), "should tell the user what to do")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learning that the server will refuse us only when a measurement fails halfway is a much
|
||||||
|
// worse experience than being told before the run starts — so the check goes both ways.
|
||||||
|
@Test
|
||||||
|
fun detectsThatTheServerWouldRefuseThisApp() {
|
||||||
|
val old = Compat.check(profile("0.4.2", appMin = "0.5.0"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.APP_REFUSED, old.verdict)
|
||||||
|
assertTrue(old.message!!.contains("0.5.0"))
|
||||||
|
|
||||||
|
val tooNew = Compat.check(profile("0.4.2", appMax = "0.3.0"), appVersion = "0.4.0")
|
||||||
|
assertEquals(Compat.Verdict.APP_REFUSED, tooNew.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A development build reports something unparseable. Locking a developer out of their own
|
||||||
|
// server would be a poor trade for a check meant to make failures clearer.
|
||||||
|
@Test
|
||||||
|
fun unknownVersionsAreUsableWithAnExplanation() {
|
||||||
|
val r = Compat.check(profile("dev"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.UNKNOWN, r.verdict)
|
||||||
|
assertTrue(r.usable, "an unidentifiable server must not be treated as incompatible")
|
||||||
|
assertNotNull(r.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aServerThatDeclaresNoWindowIsNotTreatedAsRefusingUs() {
|
||||||
|
// An older server predating the compat block sends nothing; absence must not read as
|
||||||
|
// a restriction.
|
||||||
|
val r = Compat.check(Profile(serverVersion = "0.4.2"), appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.OK, r.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The protocol version decides whether the builds *can* talk; the release window is only
|
||||||
|
// policy about whether they *may*. A protocol break must be reported as a protocol break,
|
||||||
|
// even when both release versions sit comfortably inside their windows.
|
||||||
|
@Test
|
||||||
|
fun aProtocolBreakIsReportedAsOne() {
|
||||||
|
val newer = Profile(
|
||||||
|
serverVersion = "0.9.0",
|
||||||
|
compat = CompatInfo(protocolVersion = "2.0.0", appMin = "0.2.0", appMax = "1.0.0"),
|
||||||
|
)
|
||||||
|
val r = Compat.check(newer, appVersion = "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.PROTOCOL_MISMATCH, r.verdict)
|
||||||
|
assertTrue(r.message!!.contains("2.0.0"))
|
||||||
|
|
||||||
|
// Same protocol series, different patch: fine. A protocol bugfix must not split a fleet.
|
||||||
|
val samePatch = Profile(
|
||||||
|
serverVersion = "0.9.0",
|
||||||
|
compat = CompatInfo(protocolVersion = "1.0.4", appMin = "0.2.0", appMax = "1.0.0"),
|
||||||
|
)
|
||||||
|
assertEquals(Compat.Verdict.OK, Compat.check(samePatch, appVersion = "0.2.0").verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thisBuildsOwnBoundsAreWellFormed() {
|
||||||
|
val r = Compat.serverRange
|
||||||
|
assertEquals(SemVer.parse(Compat.MIN_SERVER), r.min)
|
||||||
|
assertEquals(SemVer.parse(Compat.MAX_SERVER), r.max)
|
||||||
|
assertTrue(r.min < r.max!!, "the built-in window must be non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import (
|
|||||||
"time"
|
"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)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package compat decides whether two Echolot builds should talk to each other.
|
||||||
|
//
|
||||||
|
// Versions are SemVer. What is actually being checked, though, is not "is this release recent"
|
||||||
|
// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for
|
||||||
|
// that, and the proxy only holds because we bump the breaking axis when the contract changes.
|
||||||
|
// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must
|
||||||
|
// never strand a fleet, and the range must not need editing to ship a bugfix.
|
||||||
|
//
|
||||||
|
// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot
|
||||||
|
// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its
|
||||||
|
// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says.
|
||||||
|
package compat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of
|
||||||
|
// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0".
|
||||||
|
type Version struct {
|
||||||
|
Major, Minor, Patch int
|
||||||
|
Pre string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because
|
||||||
|
// those are the forms that actually reach this code: a tag, a --version output, and an HTTP
|
||||||
|
// header written by three different pieces of software.
|
||||||
|
func Parse(s string) (Version, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
// Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having
|
||||||
|
// no digits so a pre-release identifier that happens to contain a "v" is left alone.
|
||||||
|
if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) &&
|
||||||
|
s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
if plus := strings.IndexByte(s, '+'); plus >= 0 {
|
||||||
|
s = s[:plus]
|
||||||
|
}
|
||||||
|
var pre string
|
||||||
|
if dash := strings.IndexByte(s, '-'); dash >= 0 {
|
||||||
|
pre, s = s[dash+1:], s[:dash]
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
out := Version{Pre: pre}
|
||||||
|
for i, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
out.Major = n
|
||||||
|
case 1:
|
||||||
|
out.Minor = n
|
||||||
|
case 2:
|
||||||
|
out.Patch = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Version) String() string {
|
||||||
|
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||||
|
if v.Pre != "" {
|
||||||
|
s += "-" + v.Pre
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11);
|
||||||
|
// two pre-releases compare lexically, which is close enough for the identifiers we use.
|
||||||
|
func (v Version) Compare(o Version) int {
|
||||||
|
for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} {
|
||||||
|
if d != 0 {
|
||||||
|
return sign(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Pre == o.Pre:
|
||||||
|
return 0
|
||||||
|
case v.Pre == "":
|
||||||
|
return 1
|
||||||
|
case o.Pre == "":
|
||||||
|
return -1
|
||||||
|
case v.Pre < o.Pre:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Version) Less(o Version) bool { return v.Compare(o) < 0 }
|
||||||
|
|
||||||
|
// NextBreaking is the first version that may break compatibility with v.
|
||||||
|
//
|
||||||
|
// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's
|
||||||
|
// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a
|
||||||
|
// 0.5 server accept a 0.4 app that cannot speak to it.
|
||||||
|
func (v Version) NextBreaking() Version {
|
||||||
|
if v.Major == 0 {
|
||||||
|
return Version{Major: 0, Minor: v.Minor + 1}
|
||||||
|
}
|
||||||
|
return Version{Major: v.Major + 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded.
|
||||||
|
//
|
||||||
|
// Exclusive on the upper end because the useful bound is always "the version that broke it",
|
||||||
|
// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not.
|
||||||
|
type Range struct {
|
||||||
|
Min Version
|
||||||
|
Max Version
|
||||||
|
HasMax bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Range) Contains(v Version) bool {
|
||||||
|
if v.Less(r.Min) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.HasMax && !v.Less(r.Max) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Range) String() string {
|
||||||
|
if !r.HasMax {
|
||||||
|
return ">= " + r.Min.String()
|
||||||
|
}
|
||||||
|
return ">= " + r.Min.String() + ", < " + r.Max.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is
|
||||||
|
// an error rather than a silently ignored one: a typo in an operator's config must not quietly
|
||||||
|
// turn a restriction off.
|
||||||
|
func ParseRange(min, max string) (Range, error) {
|
||||||
|
lo, ok := Parse(min)
|
||||||
|
if !ok {
|
||||||
|
return Range{}, fmt.Errorf("bad minimum version %q", min)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(max) == "" {
|
||||||
|
return Range{Min: lo}, nil
|
||||||
|
}
|
||||||
|
hi, ok := Parse(max)
|
||||||
|
if !ok {
|
||||||
|
return Range{}, fmt.Errorf("bad maximum version %q", max)
|
||||||
|
}
|
||||||
|
if hi.Less(lo) || hi.Compare(lo) == 0 {
|
||||||
|
return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min)
|
||||||
|
}
|
||||||
|
return Range{Min: lo, Max: hi, HasMax: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdict is the outcome of a compatibility check.
|
||||||
|
type Verdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OK Verdict = iota
|
||||||
|
TooOld
|
||||||
|
TooNew
|
||||||
|
// Unknown means the peer did not say, or said something unparseable — a development build,
|
||||||
|
// or a client too old to send its version at all.
|
||||||
|
Unknown
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check reports whether peer falls in r, and explains the answer in words meant for a person.
|
||||||
|
// The message is deliberately actionable: it names both versions and what to do about it, since
|
||||||
|
// it is the only thing the user on the other end will see.
|
||||||
|
func Check(peer string, r Range, peerName string) (Verdict, string) {
|
||||||
|
v, ok := Parse(peer)
|
||||||
|
if !ok {
|
||||||
|
return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Less(r.Min):
|
||||||
|
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
|
||||||
|
peerName, v, r, peerName)
|
||||||
|
case r.HasMax && !v.Less(r.Max):
|
||||||
|
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.",
|
||||||
|
peerName, v, r, peerName)
|
||||||
|
}
|
||||||
|
return OK, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(d int) int {
|
||||||
|
if d < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if d > 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package compat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseAcceptsTheFormsThatActuallyReachUs(t *testing.T) {
|
||||||
|
cases := map[string]Version{
|
||||||
|
"1.2.3": {Major: 1, Minor: 2, Patch: 3},
|
||||||
|
"v1.2.3": {Major: 1, Minor: 2, Patch: 3},
|
||||||
|
"server-v0.4.2": {Minor: 4, Patch: 2},
|
||||||
|
" 0.2.0 ": {Minor: 2},
|
||||||
|
"1.0.0-rc1": {Major: 1, Pre: "rc1"},
|
||||||
|
"1.0.0+build.7": {Major: 1},
|
||||||
|
"1.0.0-rc1+meta": {Major: 1, Pre: "rc1"},
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got, ok := Parse(in)
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Errorf("Parse(%q) = %v,%v; want %v", in, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A pre-release identifier containing a "v" must not be mistaken for a tag prefix.
|
||||||
|
if got, ok := Parse("1.2.3-rcv1"); !ok || got.Pre != "rcv1" || got.Major != 1 {
|
||||||
|
t.Errorf("Parse(1.2.3-rcv1) = %v,%v", got, ok)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta"} {
|
||||||
|
if _, ok := Parse(bad); ok {
|
||||||
|
t.Errorf("Parse(%q) should have failed", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareOrdersPreReleasesBelowTheirRelease(t *testing.T) {
|
||||||
|
lt := func(a, b string) {
|
||||||
|
t.Helper()
|
||||||
|
x, _ := Parse(a)
|
||||||
|
y, _ := Parse(b)
|
||||||
|
if x.Compare(y) != -1 || y.Compare(x) != 1 {
|
||||||
|
t.Errorf("expected %s < %s", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lt("0.9.9", "1.0.0")
|
||||||
|
lt("1.0.0", "1.0.1")
|
||||||
|
lt("1.0.0", "1.1.0")
|
||||||
|
lt("1.0.0-rc1", "1.0.0")
|
||||||
|
lt("1.0.0-rc1", "1.0.0-rc2")
|
||||||
|
a, _ := Parse("1.2.3")
|
||||||
|
b, _ := Parse("v1.2.3")
|
||||||
|
if a.Compare(b) != 0 {
|
||||||
|
t.Error("the same version written two ways must compare equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below 1.0.0 the minor is the breaking axis. Treating 1.0.0 as the next break for a 0.x build
|
||||||
|
// would let a 0.5 server accept a 0.4 client it cannot actually talk to.
|
||||||
|
func TestNextBreakingUsesTheMinorBelowOne(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"0.4.2": "0.5.0",
|
||||||
|
"0.0.9": "0.1.0",
|
||||||
|
"1.2.3": "2.0.0",
|
||||||
|
"2.0.0": "3.0.0",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
v, _ := Parse(in)
|
||||||
|
if got := v.NextBreaking().String(); got != want {
|
||||||
|
t.Errorf("NextBreaking(%s) = %s, want %s", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeIsMinInclusiveMaxExclusive(t *testing.T) {
|
||||||
|
r, err := ParseRange("0.2.0", "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in := []string{"0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1"}
|
||||||
|
out := []string{"0.1.9", "1.0.0", "1.0.1", "2.0.0"}
|
||||||
|
for _, s := range in {
|
||||||
|
v, _ := Parse(s)
|
||||||
|
if !r.Contains(v) {
|
||||||
|
t.Errorf("%s should be inside %s", s, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range out {
|
||||||
|
v, _ := Parse(s)
|
||||||
|
if r.Contains(v) {
|
||||||
|
t.Errorf("%s should be outside %s", s, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnboundedRangeHasNoCeiling(t *testing.T) {
|
||||||
|
r, err := ParseRange("0.2.0", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, _ := Parse("99.0.0")
|
||||||
|
if !r.Contains(v) {
|
||||||
|
t.Error("an empty maximum must mean unbounded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typo in an operator's config must not silently disable the restriction it was meant to set.
|
||||||
|
func TestMalformedBoundsAreErrorsNotSilentPermissiveness(t *testing.T) {
|
||||||
|
for _, c := range [][2]string{
|
||||||
|
{"nonsense", "1.0.0"},
|
||||||
|
{"0.2.0", "nonsense"},
|
||||||
|
{"1.0.0", "0.9.0"}, // max below min
|
||||||
|
{"1.0.0", "1.0.0"}, // empty window: nothing could ever satisfy it
|
||||||
|
} {
|
||||||
|
if _, err := ParseRange(c[0], c[1]); err == nil {
|
||||||
|
t.Errorf("ParseRange(%q, %q) should have failed", c[0], c[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExplainsItself(t *testing.T) {
|
||||||
|
r, _ := ParseRange("0.2.0", "1.0.0")
|
||||||
|
|
||||||
|
if v, msg := Check("0.5.0", r, "app"); v != OK || msg != "" {
|
||||||
|
t.Errorf("in-range check should pass silently: %v %q", v, msg)
|
||||||
|
}
|
||||||
|
v, msg := Check("0.1.0", r, "app")
|
||||||
|
if v != TooOld {
|
||||||
|
t.Fatalf("want TooOld, got %v", v)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"0.1.0", "0.2.0", "Update"} {
|
||||||
|
if !contains(msg, want) {
|
||||||
|
t.Errorf("the refusal must name %q so the user can act on it: %q", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := Check("1.4.0", r, "app"); v != TooNew {
|
||||||
|
t.Errorf("want TooNew, got %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A development build reports "dev". Locking developers out of their own server would be a
|
||||||
|
// poor trade for a check that exists to prevent confusing failures.
|
||||||
|
if v, msg := Check("dev", r, "server"); v != Unknown || msg == "" {
|
||||||
|
t.Errorf("unparseable version should be Unknown with an explanation, got %v %q", v, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(h, n string) bool {
|
||||||
|
return len(h) >= len(n) && (h == n || len(n) == 0 || indexOf(h, n) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(h, n string) int {
|
||||||
|
for i := 0; i+len(n) <= len(h); i++ {
|
||||||
|
if h[i:i+len(n)] == n {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@ type Config struct {
|
|||||||
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package control
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/compat"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rangeOrDie(t *testing.T, min, max string) compat.Range {
|
||||||
|
t.Helper()
|
||||||
|
r, err := compat.ParseRange(min, max)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateRefusesOutOfRangeApps(t *testing.T) {
|
||||||
|
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
|
||||||
|
reached := false
|
||||||
|
h := s.requireCompatibleApp(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
version string
|
||||||
|
wantCode int
|
||||||
|
wantThru bool
|
||||||
|
}{
|
||||||
|
{"0.2.0", http.StatusOK, true}, // exactly the minimum is in range
|
||||||
|
{"0.9.9", http.StatusOK, true},
|
||||||
|
{"0.1.9", http.StatusUpgradeRequired, false}, // too old
|
||||||
|
{"1.0.0", http.StatusUpgradeRequired, false}, // maximum is exclusive
|
||||||
|
{"2.0.0", http.StatusUpgradeRequired, false}, // too new
|
||||||
|
{"", http.StatusOK, true}, // unknown: allowed, see below
|
||||||
|
{"dev", http.StatusOK, true}, // development build
|
||||||
|
} {
|
||||||
|
reached = false
|
||||||
|
req := httptest.NewRequest("GET", "/v1/sessions", nil)
|
||||||
|
if tc.version != "" {
|
||||||
|
req.Header.Set(AppVersionHeader, tc.version)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
if rec.Code != tc.wantCode || reached != tc.wantThru {
|
||||||
|
t.Errorf("app %q: got code=%d reached=%v, want code=%d reached=%v",
|
||||||
|
tc.version, rec.Code, reached, tc.wantCode, tc.wantThru)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal that does not say what version to install is only marginally better than a timeout.
|
||||||
|
func TestRefusalNamesTheAcceptedWindow(t *testing.T) {
|
||||||
|
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/v1/runs", nil)
|
||||||
|
req.Header.Set(AppVersionHeader, "0.1.0")
|
||||||
|
s.requireCompatibleApp(func(http.ResponseWriter, *http.Request) {})(rec, req)
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("refusal body is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"error", "app_version", "accepts_app", "protocol_version"} {
|
||||||
|
if body[key] == nil || body[key] == "" {
|
||||||
|
t.Errorf("refusal omits %q, so the client cannot explain itself: %v", key, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body["accepts_app"].(string), "0.2.0") {
|
||||||
|
t.Errorf("accepts_app should state the minimum: %v", body["accepts_app"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The profile is how a refused client learns which version it needs. Gating it would leave the
|
||||||
|
// user with a network error instead of an answer, which defeats the whole check.
|
||||||
|
func TestProfileIsReachableRegardlessOfVersion(t *testing.T) {
|
||||||
|
st, err := store.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{
|
||||||
|
AppRange: rangeOrDie(t, "9.0.0", ""), // nothing current could satisfy this
|
||||||
|
Store: st,
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("GET", "/v1/profile", nil)
|
||||||
|
req.Header.Set(AppVersionHeader, "0.1.0")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// The request carries no credential, so the handler answers 401 — the point is that it is
|
||||||
|
// the *handler* answering, not the version gate turning it into 426.
|
||||||
|
if rec.Code == http.StatusUpgradeRequired {
|
||||||
|
t.Fatal("the profile endpoint must never be gated on app version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroValueRangeFallsBackToTheBuiltInDefault(t *testing.T) {
|
||||||
|
s := &Server{} // nothing configured
|
||||||
|
got := s.appRange()
|
||||||
|
want := DefaultAppRange()
|
||||||
|
if got.Min != want.Min || got.HasMax != want.HasMax || got.Max != want.Max {
|
||||||
|
t.Fatalf("appRange() = %s, want the built-in default %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"strings"
|
"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
|
||||||
}
|
}
|
||||||
@@ -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
|
// 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 +648,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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
// 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
|
// 0 8 t_rx_ns (server clock, process epoch)
|
||||||
// 16 16 observed source IP (v4-mapped when v4)
|
// 8 8 t_tx_ns
|
||||||
// 32 2 observed source port
|
// 16 16 observed source IP (v4-mapped when v4)
|
||||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
// 32 2 observed source port
|
||||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||||
// 36 4 received size
|
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||||
|
// 36 4 received size
|
||||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
||||||
b := make([]byte, 40)
|
b := make([]byte, 40)
|
||||||
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
||||||
|
|||||||
Reference in New Issue
Block a user