From 14e5fad1b2e079bbaa78469fe413f62ca808b0d8 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 10:54:38 +0200 Subject: [PATCH] engine: downstream MTU and downstream train in the measurement document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three facts the client cannot produce alone, kept deliberately separate: mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only because the server sets DF), mtu.frag_delivery (whether larger ones arrive once fragmentation is allowed), and train.udp_downstream (loss, reordering and arrival spacing in the download direction, which a round trip cannot separate from upstream loss). ServerMeasurement now runs them on the same ProbeSession as the echo train. It had to: a fresh session restarts client-side sequence numbers and the server's anti-replay window discards the lot, so the re-primed source is never recorded and every granted send goes to a socket that has already closed. That produced four confidently-wrong FAILED tests and a RED verdict on a healthy network. Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 ++ docs/build-status.md | 78 ++++ .../echo_lot/engine/DownstreamMeasurement.kt | 344 ++++++++++++++++++ .../app/echo_lot/engine/ServerMeasurement.kt | 63 +++- .../app/echo_lot/engine/LiveDownstreamTest.kt | 68 ++++ .../echo_lot/engine/LiveMeasurementTest.kt | 7 +- .../bin/main/app/echo_lot/measurement/Test.kt | 2 + .../kotlin/app/echo_lot/measurement/Test.kt | 2 + .../app/echo_lot/protocol/ProbeSession.kt | 5 + web/README.md | 74 ++++ web/public/assets/icon.png | Bin 0 -> 23946 bytes web/public/assets/icon.svg | 24 ++ web/public/assets/social-preview.png | Bin 0 -> 26830 bytes web/public/assets/wordmark-on-dark.svg | 17 + web/public/assets/wordmark-on-light.svg | 17 + web/public/index.html | 243 ++++++++----- web/src/index.js | 61 +++- web/wrangler.jsonc | 10 +- 18 files changed, 911 insertions(+), 127 deletions(-) create mode 100644 echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/DownstreamMeasurement.kt create mode 100644 echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt create mode 100644 web/README.md create mode 100644 web/public/assets/icon.png create mode 100644 web/public/assets/icon.svg create mode 100644 web/public/assets/social-preview.png create mode 100644 web/public/assets/wordmark-on-dark.svg create mode 100644 web/public/assets/wordmark-on-light.svg diff --git a/CLAUDE.md b/CLAUDE.md index 658475c..bf03e5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,29 @@ echolot-prober/ the capability prober (self-contained Gradle buil ui/ProberScreen.kt result cards colored by verdict ``` +## Client modules (echolot-app/) + +Pure Kotlin/JVM where possible, so the interesting logic is unit-testable without a device and can +be exercised against the live server from the PC: + +- `core-protocol` — control plane (pinned TLS) + ELT1 UDP data plane. **One `ProbeSession` per + server session, for its whole lifetime**: a second one restarts sequence numbers, the server's + anti-replay window discards every packet, and granted sends then target the closed socket. +- `core-measurement` — the schema types. `core-engine` — composes probes into documents. +- `core-privacy` — the §8 anonymizer (`full` / `balanced` / `strict`). Field classification lives + in one table (`Classification.kt`); keep it there rather than annotating models. +- `core-archive` — on-device run storage + retention. `enabled` is separate from the three + ceilings: all-zeros means "no limits", not "keep nothing". + +**The local archive keeps the unredacted document; anonymization happens per upload, on the way +out.** Never redact what is stored locally. + +### Live testing without a device +`echolot-app/scripts/test-fmr.sh [gradle-task] [test-filter]` mints an enrollment token over SSH, +enrolls, computes the SPKI pin from the served cert and runs a `Live*Test` against fmr. This covers +the whole server-facing vertical (granted sends, downstream MTU, uploads) with no phone involved — +use it before asking the user to test on hardware. + ## Conventions - Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway). diff --git a/docs/build-status.md b/docs/build-status.md index a64b0e5..eb33e49 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -543,3 +543,81 @@ Best available behavior, now implemented: the banner still opens Shizuku, but th exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options** (`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless debugging must be enabled first for Shizuku's wireless start to work at all. + +### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01) +The client can measure a round trip and the largest packet it can *send*. It cannot measure the +largest packet it can *receive*, or downstream-only loss — those need the server to push, which is +exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC: + +- **`session.Grant`** — created per action, bound at creation to the session's *observed* + data-plane source (no grant without a verified destination), clamped to server limits, with a + byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals. +- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss, + reordering and inter-arrival spacing. +- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size + that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only + says whether fragments get through — a different fact, and the reason the schema has both + `mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the + startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by + our kernel is never read as a limit of the client's path. + +Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery +up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a +3000 µs send interval. + +#### Two bugs this shook out, both invisible in a single-homed lab +1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two + IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind + list. A train for a session established on `.150` left from `.151` and every packet was dropped + by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the + client saw none — reported as *100 % downstream loss*, a confident measurement of something + that never happened. Sessions now record which of our own bound addresses received their + traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the + family fallback. +2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at + zero client-side while the server's anti-replay window keeps counting, so every packet is + discarded as a replay — and because the server then never records the new source, the grant + still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole + run; `ProbeSession`'s doc comment states the constraint. + +### Run archive, anonymizer and uploads (2026-08-01) +Three pieces, deliberately separate: + +- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can + inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is + enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three + ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto + all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests. +- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes + nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a + public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour + inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics, + statuses and finding codes. Pseudonyms are consistent within a document and — by default — not + across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for + people diffing their own history. Classification is one readable table, not annotations spread + across modules. 14 tests, each pinning a property someone's privacy depends on. +- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max + runs per device, and the *least* anonymization accepted. The profile advertises all of it so the + app presents the choice honestly instead of discovering the rules by being rejected. `account` + refuses today rather than falling back to anonymous: picking the strict setting before OIDC + lands must not silently mean the loose one. + +**The archive holds the unredacted document; redaction happens on the way out, per upload.** The +local archive is the user's own data on their own device, and redacting it would destroy exactly +the detail that makes a week-old run worth keeping. + +App-side: settings screen (archive limits, privacy level with a plain-language description of what +each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether +each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer +the user cannot inspect is only a promise. + +Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the +SSDP neighbour name and the free-text note are absent from what the server stores while the +finding code and the metrics survive, then deleted it. + +### Still open +- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval. +- Enrollment UI in the app (server URL/pin/credential are typed in by hand today). +- Accounts/OIDC on the server, which is what `uploads=account` is waiting for. +- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC + against the live server. On-device verification is the next step. diff --git a/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/DownstreamMeasurement.kt b/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/DownstreamMeasurement.kt new file mode 100644 index 0000000..942c7dc --- /dev/null +++ b/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/DownstreamMeasurement.kt @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.engine + +import app.echo_lot.measurement.* +import app.echo_lot.protocol.ControlClient +import app.echo_lot.protocol.ProbeSession +import app.echo_lot.protocol.Wire +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement + +/** + * The measurements only the far end can make: what the *downstream* path does to traffic the + * client never asked for packet-by-packet. + * + * A client alone can measure a round trip, and it can find the largest packet it can *send*. It + * cannot find the largest packet it can *receive*, or whether the network drops downstream + * packets independently of upstream ones — those need a server willing to push, which is why the + * protocol gates them behind an asymmetric grant (probe-protocol.md §3.4). + * + * Three separate facts come out, and keeping them separate is the point: + * - `mtu.pmtud_down` — the largest datagram that arrives *unfragmented*. This is the number + * that matters for anything setting DF, and it is only meaningful because the server sets DF. + * - `mtu.frag_delivery` — whether larger datagrams arrive once the network is allowed to + * fragment them. A path can be fine for one and broken for the other; conflating them is how + * you get "MTU is 4000" on a link that drops every DF packet over 1400. + * - `train.udp_downstream` — loss, reordering and arrival spacing in the download direction. + */ +class DownstreamMeasurement(private val ids: IdSource) { + + private val json = Json { encodeDefaults = true; explicitNulls = true } + + /** How long to wait for a granted burst after the server accepts the action. */ + private val collectWindowMs = 4_000L + + /** + * Runs all three against an already-primed session. + * + * [session] must already have sent at least one ECHO: the grant is bound to the source the + * server has actually observed, so an unprimed session gets a 409 rather than a grant. That + * is the anti-amplification rule doing its job, not an error to work around. + */ + fun run( + credential: String, + sessionId: String, + control: ControlClient, + probe: ProbeSession, + sessionRef: String, + sizes: List = DEFAULT_SIZES, + trainCount: Int = 100, + trainSizeBytes: Int = 300, + trainIntervalUs: Int = 3_000, + ): Pair, List> { + val tests = ArrayList() + val findings = ArrayList() + + val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true) + val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false) + val train = downTrain( + credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs, + ) + + tests.add(df.test); tests.add(frag.test); tests.add(train.test) + + // A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud: + // it is the usual cause of "small requests work, large responses hang". + val pathMtu = df.largestDelivered + if (pathMtu != null && pathMtu > 0) { + val ipMtu = pathMtu + IP_UDP_OVERHEAD4 + if (ipMtu < 1500) { + findings.add( + finding( + "mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id, + "Downstream path MTU is $ipMtu bytes, below 1500", + "The largest datagram that reached this device without fragmenting was " + + "$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " + + "IPv6-in-IPv4) commonly do this; it is only a fault when something " + + "on the path also blocks the ICMP messages that let senders discover it.", + ), + ) + } + // The dangerous combination: unfragmented large packets vanish AND fragments do too, + // so a sender that never gets told will retransmit into a black hole. + val fragLargest = frag.largestDelivered ?: 0 + if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) { + findings.add( + finding( + "mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id, + "Datagrams above $pathMtu bytes are dropped downstream, fragmented or not", + "Nothing larger than $pathMtu bytes arrived, even when the network was " + + "free to fragment it. Traffic that relies on large responses will " + + "stall rather than fail cleanly.", + ), + ) + } + } + + if (train.received == 0) { + findings.add( + finding( + "connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id, + "No server-initiated packets arrived", + "The server sent ${train.sent} packets toward this device and none arrived, " + + "while the round-trip echo worked. Something on the path forwards replies " + + "but drops traffic the device did not individually solicit.", + ), + ) + } else if (train.lossPct >= 5.0) { + findings.add( + finding( + "connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id, + "Downstream loss of ${round1(train.lossPct)}%", + "${train.sent - train.received} of ${train.sent} packets sent toward this " + + "device were lost. Downstream loss is invisible to a round-trip test, " + + "which reports only that *something* was lost somewhere.", + ), + ) + } + if (train.reordered > 0) { + findings.add( + finding( + "connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id, + "${train.reordered} downstream packet(s) arrived out of order", + "Packets arrived in a different order than they were sent. Usually per-packet " + + "load balancing across links; harmless for most traffic, not for all of it.", + ), + ) + } + return tests to findings + } + + // ---- big_send --------------------------------------------------------------------- + + private class SizeResult(val test: Test, val largestDelivered: Int?) + + private fun bigSend( + credential: String, sessionId: String, control: ControlClient, probe: ProbeSession, + sessionRef: String, sizes: List, df: Boolean, + ): SizeResult { + val testId = ids.uuid() + val started = ids.monoNs() + val requested = sizes.joinToString(",") + + val reply = runCatching { + control.action( + credential, sessionId, + """{"action":"big_send","df":$df,"sizes_bytes":[$requested]}""", + ) + } + if (reply.isFailure) { + return SizeResult( + Test( + id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY, + sessionRef = sessionRef, tier = Tier.APP, + startedMonoNs = started, endedMonoNs = ids.monoNs(), + status = TestStatus.UNSUPPORTED, + error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "big_send refused"), + ), + null, + ) + } + + // The server tells us which sizes it actually put on the wire. With DF it refuses + // anything above its own egress MTU, and treating those as "lost downstream" would + // blame the client's network for our own limit. + val accepted = parseIntArray(reply.getOrNull(), "sizes_bytes").ifEmpty { sizes } + val serverMaxDf = parseInt(reply.getOrNull(), "max_df_bytes") + + val arrived = probe.collectGranted(collectWindowMs) + .filter { it.type == Wire.TYPE_BIG_SEND } + .map { it.sizeBytes } + .distinct() + .sorted() + val largest = arrived.maxOrNull() + + val metrics = json.encodeToJsonElement( + BigSendMetrics( + requestedBytes = sizes, + sentBytes = accepted, + deliveredBytes = arrived, + largestDeliveredBytes = largest, + dontFragment = df, + serverMaxDfBytes = serverMaxDf, + // Only meaningful for the DF run; the IP-level MTU is the payload plus headers. + pathMtuBytes = if (df && largest != null) largest + IP_UDP_OVERHEAD4 else null, + ), + ) as JsonObject + + val status = when { + arrived.isEmpty() -> TestStatus.FAILED + arrived.size < accepted.size -> TestStatus.PARTIAL + else -> TestStatus.OK + } + return SizeResult( + Test( + id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY, + sessionRef = sessionRef, tier = Tier.APP, + startedMonoNs = started, endedMonoNs = ids.monoNs(), + status = status, metrics = metrics, + ), + largest, + ) + } + + // ---- downtrain -------------------------------------------------------------------- + + private class TrainResult( + val test: Test, val sent: Int, val received: Int, val lossPct: Double, val reordered: Int, + ) + + private fun downTrain( + credential: String, sessionId: String, control: ControlClient, probe: ProbeSession, + sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int, + ): TrainResult { + val testId = ids.uuid() + val started = ids.monoNs() + + val reply = runCatching { + control.action( + credential, sessionId, + """{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""", + ) + } + if (reply.isFailure) { + return TrainResult( + Test( + id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, + tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(), + status = TestStatus.UNSUPPORTED, + error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "downtrain refused"), + ), + 0, 0, 0.0, 0, + ) + } + val sent = parseInt(reply.getOrNull(), "count") ?: count + + val got = probe.collectGranted(collectWindowMs).filter { it.type == Wire.TYPE_DOWNTRAIN_DATA } + val received = got.size + val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent + + // Reordering: a packet whose sequence is below the highest already seen. Counting + // inversions rather than "not sorted" keeps one late packet from being reported as + // dozens of reorder events. + var highest = -1 + var reordered = 0 + for (p in got) { + if (p.seq < highest) reordered++ else highest = p.seq + } + + // Columnar evidence per the schema: what arrived, when, and how big — so every metric + // above is recomputable by a reader who does not trust our arithmetic. + val evidence = TrainEvidence( + epochMonoNs = started, + seq = got.map { it.seq }, + tTxNs = got.map { null }, + tRxNs = got.map { it.tRxNs }, + sizeBytes = got.map { it.sizeBytes }, + ).toEvidence() + + val interArrival = got.zipWithNext { a, b -> (b.tRxNs - a.tRxNs) / 1_000_000.0 } + val metrics = json.encodeToJsonElement( + DownTrainMetrics( + sent = sent, received = received, lossPct = round1(lossPct), + reorderedPackets = reordered, + sizeBytes = sizeBytes, + interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1), + interArrivalMsMax = interArrival.maxOrNull()?.let(::round1), + sendIntervalUs = intervalUs, + ), + ) as JsonObject + + val status = when { + received == 0 -> TestStatus.FAILED + received < sent -> TestStatus.PARTIAL + else -> TestStatus.OK + } + return TrainResult( + Test( + id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, tier = Tier.APP, + startedMonoNs = started, endedMonoNs = ids.monoNs(), + status = status, evidence = evidence, metrics = metrics, + ), + sent, received, lossPct, reordered, + ) + } + + // ---- helpers ---------------------------------------------------------------------- + + private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) = + Finding( + id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH, + title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)), + ) + + /** Minimal scalar extraction from the action reply; the shape is small and server-owned. */ + private fun parseInt(body: String?, key: String): Int? = + body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() } + + private fun parseIntArray(body: String?, key: String): List = + body?.let { b -> + Regex("\"$key\"\\s*:\\s*\\[([^\\]]*)\\]").find(b)?.groupValues?.get(1) + ?.split(",")?.mapNotNull { it.trim().toIntOrNull() } + } ?: emptyList() + + private companion object { + /** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */ + const val IP_UDP_OVERHEAD4 = 28 + + /** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */ + val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000) + + fun round1(v: Double) = Math.round(v * 10.0) / 10.0 + } +} + +/** Metrics for mtu.pmtud_down / mtu.frag_delivery. */ +@Serializable +data class BigSendMetrics( + @SerialName("requested_bytes") val requestedBytes: List, + @SerialName("sent_bytes") val sentBytes: List, + @SerialName("delivered_bytes") val deliveredBytes: List, + @SerialName("largest_delivered_bytes") val largestDeliveredBytes: Int? = null, + @SerialName("dont_fragment") val dontFragment: Boolean, + /** The server's own DF ceiling; sizes above it were never sent and are not path evidence. */ + @SerialName("server_max_df_bytes") val serverMaxDfBytes: Int? = null, + @SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null, +) + +/** Metrics for train.udp_downstream. */ +@Serializable +data class DownTrainMetrics( + val sent: Int, + val received: Int, + @SerialName("loss_pct") val lossPct: Double, + @SerialName("reordered_packets") val reorderedPackets: Int, + @SerialName("size_bytes") val sizeBytes: Int, + @SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null, + @SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null, + @SerialName("send_interval_us") val sendIntervalUs: Int, +) diff --git a/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/ServerMeasurement.kt b/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/ServerMeasurement.kt index bc1ba68..90f4939 100644 --- a/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/ServerMeasurement.kt +++ b/echolot-app/core-engine/src/main/kotlin/app/echo_lot/engine/ServerMeasurement.kt @@ -36,6 +36,12 @@ class ServerMeasurement( val udpPort: Int, val echoCount: Int = 20, val echoPaddingBytes: Int = 64, + /** + * Whether to ask the server to push traffic back (downstream MTU and downstream train). + * Costs a few hundred kB of download and needs a server that advertises the grants, so + * it is a flag rather than an assumption. + */ + val downstream: Boolean = true, ) fun run(cfg: Config): MeasurementDocument { @@ -57,11 +63,32 @@ class ServerMeasurement( target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort), ) - val (test, findings) = echoTrain(cfg, control, session, startMono) + val tests = ArrayList() + val allFindings = ArrayList() + + // One ProbeSession for the whole run. A second one would open a new socket and restart + // the sequence counter, which the server's anti-replay window correctly rejects — so the + // re-primed source is never recorded and every granted send goes to the old, closed port. + // Session identity lives on the server; the socket must live as long as it does. + ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps -> + val (test, findings) = echoTrain(cfg, ps, startMono) + tests.add(test) + allFindings.addAll(findings) + + // Downstream needs a session the server has already seen traffic from — the echo + // train just provided that — and a server that advertises the grants. Skipped + // quietly against an older server rather than reported as a failure of the network. + if (cfg.downstream && profile.supports("downtrain") && profile.supports("big-send")) { + val (dsTests, dsFindings) = DownstreamMeasurement(ids) + .run(cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1") + tests.addAll(dsTests) + allFindings.addAll(dsFindings) + } + } control.deleteSession(cfg.credential, session.sessionId) - val summary = Verdicts.derive(listOf(test), findings) + val summary = Verdicts.derive(tests, allFindings) return MeasurementDocument( run = Run( id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(), @@ -70,14 +97,14 @@ class ServerMeasurement( tiers = Tiers(app = true), ), serverSessions = listOf(serverSession), - tests = listOf(test), - findings = findings, + tests = tests, + findings = allFindings, summary = summary, ) } private fun echoTrain( - cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long, + cfg: Config, ps: ProbeSession, startMono: Long, ): Pair> { val testId = ids.uuid() val seqs = ArrayList() @@ -87,20 +114,18 @@ class ServerMeasurement( val rtts = ArrayList() val observedPorts = LinkedHashSet() - ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps -> - for (i in 0 until cfg.echoCount) { - val txMono = ids.monoNs() - startMono - val r = ps.echo(cfg.echoPaddingBytes) - seqs.add(i) - tTx.add(txMono) - sizes.add(Wire_HEADER + cfg.echoPaddingBytes) - if (r != null) { - tRx.add(ids.monoNs() - startMono) - rtts.add(r.rttMs) - r.observation?.observedPort?.let { observedPorts.add(it) } - } else { - tRx.add(null) - } + for (i in 0 until cfg.echoCount) { + val txMono = ids.monoNs() - startMono + val r = ps.echo(cfg.echoPaddingBytes) + seqs.add(i) + tTx.add(txMono) + sizes.add(Wire_HEADER + cfg.echoPaddingBytes) + if (r != null) { + tRx.add(ids.monoNs() - startMono) + rtts.add(r.rttMs) + r.observation?.observedPort?.let { observedPorts.add(it) } + } else { + tRx.add(null) } } diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt new file mode 100644 index 0000000..548125f --- /dev/null +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveDownstreamTest.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package app.echo_lot.engine + +import app.echo_lot.measurement.TestStatus +import app.echo_lot.measurement.TestType +import app.echo_lot.protocol.ControlClient +import app.echo_lot.protocol.ProbeSession +import kotlin.test.Test as JTest +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not + * just that packets moved: the tests must carry recomputable metrics and land on the right test + * types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*. + */ +class LiveDownstreamTest { + + private val url = System.getenv("ECHOLOT_LIVE_URL") + private val pin = System.getenv("ECHOLOT_LIVE_PIN") + private val cred = System.getenv("ECHOLOT_LIVE_CRED") + private val udp = System.getenv("ECHOLOT_LIVE_UDP") + private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr" + + @JTest + fun producesDownstreamTestsAndFindings() { + if (url == null || pin == null || cred == null || udp == null) { + println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return + } + val control = ControlClient(url, setOf(pin)) + val session = control.createSession(cred, target) + val (host, port) = udp.split(":").let { it[0] to it[1].toInt() } + + val (tests, findings) = ProbeSession(cred, session, host, port).use { ps -> + ps.echo() // prime: the grant binds to the source the server has actually observed + DownstreamMeasurement(SystemIdSource()) + .run(cred, session.sessionId, control, ps, sessionRef = "sess-1") + } + control.deleteSession(cred, session.sessionId) + + for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}") + for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}") + + assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train") + val byType = tests.associateBy { it.type } + + val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test") + assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL, + "DF probe did not deliver anything: ${pmtud.status}") + val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull() + assertNotNull(pathMtu, "pmtud_down must report a path MTU") + assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu") + println("downstream path MTU = $pathMtu bytes") + + val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test") + assertNotNull(frag.metrics?.get("largest_delivered_bytes")) + + val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train") + assertNotNull(train.evidence, "a train without columnar evidence is not recomputable") + val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0 + assertTrue(received > 0, "no downstream train packets arrived") + println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " + + "reordered=${train.metrics?.get("reordered_packets")}") + } +} diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveMeasurementTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveMeasurementTest.kt index 1619b38..d2ce1a3 100644 --- a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveMeasurementTest.kt +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveMeasurementTest.kt @@ -47,8 +47,11 @@ class LiveMeasurementTest { assertEquals(1, doc.serverSessions.size) assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe")) - val test = doc.tests.single() - assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type) + // A full run is the echo train plus the three downstream tests; assert on the one this + // test is about rather than on the count, so adding a measurement is not a test edit. + for (t in doc.tests) println(" ${t.type} → ${t.status}") + for (f in doc.findings) println(" finding ${f.code} [${f.severity}] ${f.title}") + val test = doc.tests.first { it.type == TestType.TRAIN_UDP_UPDOWN } assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL, "expected replies from live server, got ${test.status}") diff --git a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt index c26f202..3393dcd 100644 --- a/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/bin/main/app/echo_lot/measurement/Test.kt @@ -69,6 +69,8 @@ object TestType { const val TRACEROUTE_ICMP6 = "traceroute.icmp6" // train const val TRAIN_UDP_UPDOWN = "train.udp_updown" + /** Server-to-client train under a §3.4 grant: the direction a round trip cannot separate. */ + const val TRAIN_UDP_DOWNSTREAM = "train.udp_downstream" // mtu const val MTU_PMTUD_UP = "mtu.pmtud_up" const val MTU_PMTUD_DOWN = "mtu.pmtud_down" diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt index c26f202..3393dcd 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/Test.kt @@ -69,6 +69,8 @@ object TestType { const val TRACEROUTE_ICMP6 = "traceroute.icmp6" // train const val TRAIN_UDP_UPDOWN = "train.udp_updown" + /** Server-to-client train under a §3.4 grant: the direction a round trip cannot separate. */ + const val TRAIN_UDP_DOWNSTREAM = "train.udp_downstream" // mtu const val MTU_PMTUD_UP = "mtu.pmtud_up" const val MTU_PMTUD_DOWN = "mtu.pmtud_down" diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt index 6266726..76803a2 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ProbeSession.kt @@ -12,6 +12,11 @@ import java.util.Base64 * A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1 * packets to the server's UDP endpoint and reads back verified responses. One session ↔ one * server target. Blocking; the caller owns threading. + * + * One instance per server session, for its whole lifetime. Sequence numbers start at zero here + * while the server's anti-replay window (§3.2) keeps counting, so a second instance sharing a + * session id has all its packets discarded as replays — and, because the server then never + * records the new source, any granted send still targets the socket that was closed. */ class ProbeSession( private val credential: String, diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..f554032 --- /dev/null +++ b/web/README.md @@ -0,0 +1,74 @@ +# Echolot website (`web/`) + +Minimal single-page site for [echo-lot.app](https://echo-lot.app), served from Cloudflare +Workers. Static files in `public/` are served straight from the edge; the tiny Worker in +`src/index.js` only runs for paths that aren't files: + +| Path | Behavior | +| ------------- | ------------------------------------------------------------------------ | +| `/apk` | 302 → newest `.apk` asset of the latest Gitea release (QR-code friendly) | +| `/apk.sha256` | 302 → the matching `.sha256` asset | +| `/api/latest` | JSON `{version, published_at, apk, sha256}` — the homepage's version readout | +| `/fdroid`, `/source` | 302 → the URLs configured in `wrangler.jsonc` vars | + +The latest release is resolved from the Gitea API **at request time** (edge-cached 5 min), so +publishing a release — `git tag v0.2.0 && git push origin v0.2.0`, which triggers +`.gitea/workflows/release.yml` — is the only release step. The site never needs a redeploy for +a new version, and empty/unreachable values fall back to the homepage instead of 404ing. + +Light/dark follows the OS (`prefers-color-scheme`), no toggle, no JS required for it. Colors +come from the branding palette (teal = instrument, single amber point = finding). + +`public/assets/` (favicon, wordmark, social preview) are **copies** of `../assets/branding/` — +that directory is the source of truth; re-copy after any branding change. + +## Deploy + +Everything is driven by [wrangler](https://developers.cloudflare.com/workers/wrangler/), config +in `wrangler.jsonc`. No build step, no node_modules to commit. + +### One-time setup + +1. In the Cloudflare dashboard, add **echo-lot.app** as a zone (and point the domain's + nameservers at Cloudflare). The `routes` in `wrangler.jsonc` use `custom_domain: true`, so + wrangler creates the DNS records for `echo-lot.app` and `www` automatically on first deploy — + the zone just has to exist in the same account. +2. Auth, either flavor: + - **Interactive:** `npx wrangler login` (opens the browser once, stores an OAuth token). + - **API token (also what CI uses):** dashboard → My Profile → API Tokens → create from the + **"Edit Cloudflare Workers"** template. Then: + + ``` + $env:CLOUDFLARE_API_TOKEN = "..." # PowerShell; export ... on POSIX + $env:CLOUDFLARE_ACCOUNT_ID = "..." # dashboard → Workers & Pages, right sidebar + ``` + +### Deploy + +``` +cd web +npx wrangler@4 deploy +``` + +That's it — uploads `src/index.js` + the `public/` assets, wires the custom domains. Useful +extras: `npx wrangler dev` (local preview at localhost:8787), `npx wrangler tail` (live logs), +`npx wrangler versions list`. + +### CI deploy (Gitea Actions) + +`.gitea/workflows/deploy-site.yml` runs `wrangler deploy` on every push to `main`/`master` that +touches `web/`. It stays inert until you add two repo secrets (Settings → Actions → Secrets): +`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (same values as above). + +Cloudflare's raw REST API (`PUT /accounts/:id/workers/scripts/...`) exists, but the assets +upload needs a manifest/session dance that wrangler already implements — use wrangler even in +automation. + +## Config knobs (`wrangler.jsonc` → `vars`) + +- `GITEA_REPO_API` — Gitea repo API base; releases must be publicly readable. +- `DOWNLOAD_URL` — manual `/apk` fallback while Gitea is unreachable. +- `FDROID_URL` — set when the F-Droid listing exists; until then `/fdroid` loops home. +- `SOURCE_URL` — public source mirror for the footer + `/source`. + +Vars are plain (non-secret) config; change + `wrangler deploy` to apply. diff --git a/web/public/assets/icon.png b/web/public/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..15ae7f733aa81c3fc654c3b1653a202c46b98795 GIT binary patch literal 23946 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 QgI^H(+D2LxnhuZt4}y@szyJUM literal 0 HcmV?d00001 diff --git a/web/public/assets/icon.svg b/web/public/assets/icon.svg new file mode 100644 index 0000000..cc36d74 --- /dev/null +++ b/web/public/assets/icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/assets/social-preview.png b/web/public/assets/social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..54654d1b3549894baa256a42e285d64988bd86dd GIT binary patch literal 26830 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 + + + + + + + + + + + + + + + + diff --git a/web/public/assets/wordmark-on-light.svg b/web/public/assets/wordmark-on-light.svg new file mode 100644 index 0000000..49a0cf6 --- /dev/null +++ b/web/public/assets/wordmark-on-light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/public/index.html b/web/public/index.html index 0a78ab7..6331895 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -5,103 +5,124 @@ -Echolot — depth soundings for your local network +Echolot — measure, don't guess - + - + + + + + +