engine: downstream MTU and downstream train in the measurement document

Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

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

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

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

After

Width:  |  Height:  |  Size: 23 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

After

Width:  |  Height:  |  Size: 742 B

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

After

Width:  |  Height:  |  Size: 743 B

+156 -87
View File
@@ -5,103 +5,124 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Echolot — depth soundings for your local network</title> <title>Echolot — measure, don't guess</title>
<meta name="description" content="Free Android app for detecting and debugging local network issues: rogue DHCP, broken IPv6 RAs, MTU black holes, multicast loss, lying DNS. No root required."> <meta name="description" content="Free Android app for detecting and debugging local network issues: rogue DHCP, broken IPv6 RAs, MTU black holes, multicast loss, lying DNS. No root required.">
<meta property="og:title" content="Echolot"> <meta property="og:title" content="Echolot">
<meta property="og:description" content="Depth soundings for your local network. F/OSS Android network diagnostics — no root required."> <meta property="og:description" content="Measure, don't guess. F/OSS Android network diagnostics — no root required.">
<meta property="og:url" content="https://echo-lot.app/"> <meta property="og:url" content="https://echo-lot.app/">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23071A29'/%3E%3Cg fill='none' stroke='%23FFB454' stroke-width='2'%3E%3Ccircle cx='16' cy='16' r='3' fill='%23FFB454' stroke='none'/%3E%3Cpath d='M16 6a10 10 0 0 1 10 10'/%3E%3Cpath d='M16 1a15 15 0 0 1 15 15' opacity='.5'/%3E%3C/g%3E%3C/svg%3E"> <meta property="og:image" content="https://echo-lot.app/assets/social-preview.png">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#071522">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2F6F7">
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/icon.png" type="image/png" sizes="512x512">
<link rel="apple-touch-icon" href="/assets/icon.png">
<style> <style>
/* Branding: teal is always the instrument (links, brackets, controls);
the single amber point is the finding (the focused node, the version
readout). Dark = the instrument's own display; light = the same tokens
on paper. Palette from assets/branding/. */
:root { :root {
--depth-0: #0B2437; /* surface */ color-scheme: dark;
--depth-1: #092031; /* photic */ --bg-0: #0C2130; /* top of page */
--depth-2: #071A29; /* mid */ --bg-1: #071522; /* abyss — page floor, panel ground */
--depth-3: #051320; /* floor */ --tile: #0E2433; /* raised surfaces */
--foam: #DCE9F1; /* primary text */ --foam: #E8F4F2; /* primary text */
--slate: #8AA5B8; /* secondary text */ --slate: #7DA2AC; /* secondary text */
--grid: #16374E; /* hairlines, chart grid */ --caption:#5E8B96; /* mono captions, from the banner */
--ping: #FFB454; /* the one accent: sonar amber */ --grid: #10303F; /* hairlines */
--ok: #7BC98F; /* verdict green, chips only */ --rest: #1E4A5C; /* the network, at rest */
--teal: #35E0C4; /* instrument */
--on-teal:#04212B; /* text on teal */
--amber: #FFB454; /* the finding */
--mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; --mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; --sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
} }
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-0: #F2F6F7;
--bg-1: #E4ECEF;
--tile: #EBF1F3;
--foam: #1B3540; /* ink, from wordmark-on-light */
--slate: #47656F;
--caption:#5E8B96;
--grid: #C4D3D8;
--rest: #9FB8C1;
--teal: #0E9384; /* instrument, printable contrast */
--on-teal:#F5FBFA;
--amber: #C77413; /* finding ink */
}
}
* { box-sizing: border-box; margin: 0; } * { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { body {
font-family: var(--sans); font-family: var(--sans);
color: var(--foam); color: var(--foam);
background: linear-gradient(var(--depth-0), var(--depth-1) 30%, var(--depth-2) 65%, var(--depth-3)); background: linear-gradient(var(--bg-0), var(--bg-1) 70%);
min-height: 100vh;
line-height: 1.6; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
a { color: var(--ping); text-decoration-thickness: 1px; text-underline-offset: 3px; } a { color: var(--teal); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { text-decoration-thickness: 2px; } a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 2px solid var(--ping); outline-offset: 3px; border-radius: 2px; } :focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; border-radius: 2px; }
.col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; } .col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; }
/* Depth ruler: fixed left margin scale, desktop only. Marks are set per-section /* Graduated rule: the tick motif from the banner, vertical. Fixed left
by scroll position purely decoratively — it is a ruler, not navigation. */ margin, desktop only, purely decorative. */
.ruler { .rule {
position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem; position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem;
border-right: 1px solid var(--grid); border-right: 1px solid var(--grid);
font-family: var(--mono); font-size: .65rem; color: var(--slate);
display: none; display: none;
} }
@media (min-width: 72rem) { .ruler { display: block; } } @media (min-width: 72rem) { .rule { display: block; } }
.ruler span { .rule::after {
position: absolute; right: .5rem; transform: translateY(-50%); content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: .4rem;
} background: repeating-linear-gradient(to bottom, var(--rest) 0 1.5px, transparent 1.5px 60px);
.ruler span::after {
content: ""; position: absolute; right: -.55rem; top: 50%;
width: .35rem; height: 1px; background: var(--slate);
} }
header.hero { padding: 4.5rem 0 3rem; } header.hero { padding: 4.5rem 0 3rem; }
.wordmark { .wordmark { display: block; height: 30px; width: auto; }
font-family: var(--mono); font-size: .8rem; letter-spacing: .35em;
text-transform: uppercase; color: var(--slate);
}
.wordmark b { color: var(--ping); font-weight: 600; }
h1 { h1 {
font-size: clamp(1.9rem, 5vw, 3rem); font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 650; letter-spacing: -.02em; line-height: 1.15; font-weight: 650; letter-spacing: -.02em; line-height: 1.15;
margin: 1rem 0 .75rem; max-width: 30ch; margin: 1.75rem 0 .75rem; max-width: 30ch;
} }
.hero p.lede { color: var(--slate); max-width: 52ch; font-size: 1.05rem; } .hero p.lede { color: var(--slate); max-width: 52ch; font-size: 1.05rem; }
.hero p.lede strong { color: var(--foam); font-weight: 600; } .hero p.lede strong { color: var(--foam); font-weight: 600; }
/* Echogram: the signature. A chart-recorder trace of ping RTTs; the sweep /* Focus panel: the signature, straight from the mark. The network at rest,
line is the sounder, the profile is the "seabed" the echoes draw. */ one node under examination — teal brackets are the instrument, the amber
figure.echogram { point is the finding. */
figure.focus {
margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px; margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px;
background: background: var(--tile);
repeating-linear-gradient(to right, transparent 0 39px, var(--grid) 39px 40px),
repeating-linear-gradient(to bottom, transparent 0 31px, var(--grid) 31px 32px),
var(--depth-3);
position: relative; overflow: hidden; position: relative; overflow: hidden;
} }
.echogram svg { display: block; width: 100%; height: auto; } .focus svg { display: block; width: 100%; height: auto; }
.echogram figcaption { .focus .rest-node { fill: var(--rest); }
.focus .bracket { fill: none; stroke: var(--teal); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.focus .finding { fill: var(--amber); }
.focus .halo { fill: none; stroke: var(--amber); stroke-width: 2; opacity: .3; }
.focus .readout { font-family: var(--mono); font-size: 11px; fill: var(--slate); }
.focus .readout .flag { fill: var(--amber); }
.focus .lead { stroke: var(--grid); stroke-width: 1; }
.focus figcaption {
position: absolute; top: .5rem; left: .75rem; position: absolute; top: .5rem; left: .75rem;
font-family: var(--mono); font-size: .65rem; color: var(--slate); font-family: var(--mono); font-size: .65rem; color: var(--caption);
} }
.sweep { @keyframes examine { 0%, 100% { opacity: .3; } 50% { opacity: .1; } }
position: absolute; top: 0; bottom: 0; width: 1px; .focus .halo { animation: examine 3.2s ease-in-out infinite; }
background: var(--ping); opacity: .8;
box-shadow: 0 0 8px var(--ping);
animation: sweep 7s linear infinite;
}
@keyframes sweep { from { left: 0; } to { left: 100%; } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sweep { animation: none; left: 62%; } .focus .halo { animation: none; }
html { scroll-behavior: auto; } html { scroll-behavior: auto; }
} }
section { padding: 3.5rem 0 0; } section { padding: 3.5rem 0 0; }
.eyebrow { .eyebrow {
font-family: var(--mono); font-size: .7rem; letter-spacing: .25em; font-family: var(--mono); font-size: .7rem; letter-spacing: .25em;
text-transform: uppercase; color: var(--ping); text-transform: uppercase; color: var(--teal);
} }
h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; } h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; }
section > .col > p { color: var(--slate); max-width: 58ch; } section > .col > p { color: var(--slate); max-width: 58ch; }
@@ -129,21 +150,26 @@
border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem; border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem;
color: var(--slate); color: var(--slate);
} }
.tier b { color: var(--ok); font-weight: 600; } .tier b { color: var(--teal); font-weight: 600; }
/* Install */ /* Install */
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1.5rem 0 1rem; } .release {
font-family: var(--mono); font-size: .8rem; color: var(--slate);
margin-top: 1.25rem;
}
.release b { color: var(--amber); font-weight: 600; }
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1rem 0 1rem; }
.btn { .btn {
display: inline-block; padding: .7rem 1.3rem; border-radius: 4px; display: inline-block; padding: .7rem 1.3rem; border-radius: 4px;
font-weight: 600; text-decoration: none; font-size: .95rem; font-weight: 600; text-decoration: none; font-size: .95rem;
} }
.btn.primary { background: var(--ping); color: var(--depth-3); } .btn.primary { background: var(--teal); color: var(--on-teal); }
.btn.primary:hover { filter: brightness(1.08); } .btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { border: 1px solid var(--grid); color: var(--foam); } .btn.ghost { border: 1px solid var(--grid); color: var(--foam); }
.btn.ghost:hover { border-color: var(--slate); } .btn.ghost:hover { border-color: var(--slate); }
.note { .note {
font-size: .85rem; color: var(--slate); font-size: .85rem; color: var(--slate);
border-left: 2px solid var(--ping); padding-left: .9rem; max-width: 52ch; border-left: 2px solid var(--teal); padding-left: .9rem; max-width: 52ch;
} }
.checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; } .checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; }
@@ -157,45 +183,51 @@
</head> </head>
<body> <body>
<div class="ruler" aria-hidden="true"> <div class="rule" aria-hidden="true"></div>
<span style="top:6%">0 m</span>
<span style="top:28%">─ 20</span>
<span style="top:50%">─ 40</span>
<span style="top:72%">─ 60</span>
<span style="top:94%">─ 80</span>
</div>
<header class="hero"> <header class="hero">
<div class="col"> <div class="col">
<p class="wordmark"><b></b> echo·lot <span aria-hidden="true">/ˈɛçolo:t/ — echo sounder</span></p> <picture>
<h1>Depth soundings for your local network.</h1> <source srcset="/assets/wordmark-on-dark.svg" media="(prefers-color-scheme: dark)">
<p class="lede">An echo sounder maps the seabed by timing returns. <strong>Echolot</strong> does the <img class="wordmark" src="/assets/wordmark-on-light.svg" alt="echolot" width="125" height="30">
same to your network: free Android diagnostics for the layer where things actually break — </picture>
<h1>Measure, don't guess.</h1>
<p class="lede">Free Android diagnostics for the layer where networks actually break.
<strong>Echolot</strong> takes the failure you can feel and pins it to a fact you can show —
<strong>no root required</strong>. Built for people who know what a neighbor table is.</p> <strong>no root required</strong>. Built for people who know what a neighbor table is.</p>
<figure class="echogram"> <figure class="focus">
<figcaption>trace · icmp.ping4 · rtt ms ↓ / t →</figcaption> <figcaption>focus · dhcp.rogue_detect · tier:app</figcaption>
<svg viewBox="0 0 720 190" role="img" aria-label="Chart-recorder style trace of ping round-trip times, drawn like a sonar seabed profile"> <svg viewBox="0 0 720 240" role="img" aria-label="A grid of network nodes at rest; one node is framed by viewfinder brackets, highlighted as a finding: two DHCP servers answered the same DISCOVER">
<!-- echo returns: the profile --> <!-- the network, at rest -->
<polyline fill="none" stroke="#FFB454" stroke-width="1.5" opacity=".9" <g class="rest-node">
points="0,138 40,136 80,139 120,135 160,137 200,141 240,138 260,120 280,96 300,88 320,94 340,118 360,134 400,136 440,133 480,158 500,171 520,168 540,150 560,139 600,137 640,140 680,136 720,138"/> <circle cx="120" cy="60" r="3"/><circle cx="240" cy="60" r="3"/><circle cx="360" cy="60" r="3"/><circle cx="480" cy="60" r="3"/><circle cx="600" cy="60" r="3"/>
<!-- second, fainter return (multipath) --> <circle cx="120" cy="120" r="3"/><circle cx="480" cy="120" r="3"/><circle cx="600" cy="120" r="3"/>
<polyline fill="none" stroke="#FFB454" stroke-width="1" opacity=".25" <circle cx="120" cy="180" r="3"/><circle cx="240" cy="180" r="3"/><circle cx="360" cy="180" r="3"/><circle cx="480" cy="180" r="3"/><circle cx="600" cy="180" r="3"/>
points="0,148 40,146 80,149 120,145 160,147 200,151 240,148 260,132 280,110 300,101 320,107 340,129 360,144 400,146 440,143 480,168 500,180 520,177 540,160 560,149 600,147 640,150 680,146 720,148"/> </g>
<!-- dropped probes --> <!-- one node, under examination -->
<g fill="#8AA5B8" font-family="monospace" font-size="9"> <circle class="halo" cx="240" cy="120" r="11"/>
<text x="497" y="30">×</text><text x="507" y="30">×</text> <circle class="finding" cx="240" cy="120" r="5"/>
<text x="288" y="30">▲ spike: wifi→cell handover</text> <g class="bracket">
<path d="M 222 111 V 102 H 231"/>
<path d="M 249 102 H 258 V 111"/>
<path d="M 258 129 V 138 H 249"/>
<path d="M 231 138 H 222 V 129"/>
</g>
<!-- the readout -->
<line class="lead" x1="262" y1="120" x2="296" y2="120"/>
<g class="readout">
<text x="304" y="112">DISCOVER → 2 OFFERs</text>
<text x="304" y="130">192.168.1.1 gw · <tspan class="flag">192.168.1.223 — who is this?</tspan></text>
</g> </g>
</svg> </svg>
<div class="sweep" aria-hidden="true"></div>
</figure> </figure>
</div> </div>
</header> </header>
<section id="what"> <section id="what">
<div class="col"> <div class="col">
<p class="eyebrow">What it sounds out</p> <p class="eyebrow">What it measures</p>
<h2>Signal bars lie. Timings don't.</h2> <h2>Signal bars lie. Timings don't.</h2>
<p>Most wifi apps show you signal strength and call it a diagnosis. The failures that ruin <p>Most wifi apps show you signal strength and call it a diagnosis. The failures that ruin
home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router
@@ -234,15 +266,16 @@
<div class="col"> <div class="col">
<p class="eyebrow">Install</p> <p class="eyebrow">Install</p>
<h2>Get Echolot</h2> <h2>Get Echolot</h2>
<p class="release" id="release" hidden></p>
<div class="buttons"> <div class="buttons">
<a class="btn primary" href="/apk">Download APK</a> <a class="btn primary" id="dl-btn" href="/apk">Download APK</a>
<a class="btn ghost" href="/fdroid">F-Droid</a> <a class="btn ghost" href="/fdroid">F-Droid</a>
</div> </div>
<p class="note"><strong>Pre-release.</strong> The capability prober is running on real <p class="note" id="prerelease-note"><strong>Pre-release.</strong> The capability prober is
hardware; the production app is under construction. These links go live with the first running on real hardware; the production app is under construction. These links go live with
release — until then they loop back here. No mailing list, no tracker: check back, or watch the first release — until then they loop back here. No mailing list, no tracker: check back,
the <a href="/source">repository</a>.</p> or watch the <a href="/source">repository</a>.</p>
<p class="checksum">releases will ship with sha256sums + a signing key you can pin</p> <p class="checksum" id="checksum">releases will ship with sha256sums + a signing key you can pin</p>
</div> </div>
</section> </section>
@@ -253,5 +286,41 @@
</div> </div>
</footer> </footer>
<script>
// Release readout: asks this site's own Worker (/api/latest, which proxies the
// Gitea "latest release" API, edge-cached). Progressive enhancement — with no
// JS, no network, or no release yet, the static pre-release copy above stands.
(async () => {
let rel;
try {
const res = await fetch("/api/latest");
if (!res.ok) return;
rel = await res.json();
} catch { return; }
if (!rel || !rel.available) return;
const line = document.getElementById("release");
const ver = document.createElement("b");
ver.textContent = rel.version;
line.append("» latest ", ver);
const date = (rel.published_at || "").slice(0, 10);
if (date) line.append(" · " + date);
if (rel.apk && rel.apk.size) {
line.append(" · " + (rel.apk.size / 1048576).toFixed(1) + " MiB");
}
line.hidden = false;
document.getElementById("prerelease-note").hidden = true;
if (rel.sha256) {
const c = document.getElementById("checksum");
c.textContent = "";
const a = document.createElement("a");
a.href = "/apk.sha256";
a.textContent = "sha256";
c.append(a, " · verify before you sideload");
}
})();
</script>
</body> </body>
</html> </html>
+47 -14
View File
@@ -3,28 +3,32 @@
// Everything under public/ is served straight from the edge without invoking // Everything under public/ is served straight from the edge without invoking
// this Worker. The Worker exists for the short stable URLs (/apk, /fdroid, // this Worker. The Worker exists for the short stable URLs (/apk, /fdroid,
// /source) — short enough for a QR code — and to resolve "/apk" to the newest // /source) — short enough for a QR code — and for /api/latest, which the
// release asset at request time, so tagging a release in Gitea is the only // homepage uses to show the current version. Both resolve the newest release
// publish step. No site redeploy, no URL to update. // from the Gitea API at request time, so tagging a release in Gitea is the
// only publish step. No site redeploy, no URL to update.
const STATIC_ROUTES = { const STATIC_ROUTES = {
"/fdroid": "FDROID_URL", "/fdroid": "FDROID_URL",
"/source": "SOURCE_URL", "/source": "SOURCE_URL",
}; };
// Resolve the newest APK from the Gitea "latest release" API. Cached at the // Fetch the Gitea "latest release" object. Cached at the edge for 5 minutes so
// edge for 5 minutes so a release becomes visible quickly, while Gitea sees // a new release becomes visible quickly, while Gitea sees at most one API hit
// at most one API hit per POP per 5 min regardless of download traffic. // per POP per 5 min regardless of traffic. Returns null on any failure —
async function latestApkUrl(env) { // callers degrade to fallbacks rather than surfacing errors.
async function latestRelease(env) {
if (!env.GITEA_REPO_API) return null; if (!env.GITEA_REPO_API) return null;
const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, { const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, {
headers: { Accept: "application/json", "User-Agent": "echolot-site" }, headers: { Accept: "application/json", "User-Agent": "echolot-site" },
cf: { cacheTtl: 300, cacheEverything: true }, cf: { cacheTtl: 300, cacheEverything: true },
}); });
if (!res.ok) return null; if (!res.ok) return null;
const rel = await res.json(); return res.json();
const apk = rel.assets?.find((a) => a.name?.endsWith(".apk")); }
return apk?.browser_download_url ?? null;
function asset(rel, suffix) {
return rel?.assets?.find((a) => a.name?.endsWith(suffix)) ?? null;
} }
function redirect(location) { function redirect(location) {
@@ -38,21 +42,50 @@ function redirect(location) {
}); });
} }
function json(body, maxAge) {
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${maxAge}`,
},
});
}
export default { export default {
async fetch(request, env) { async fetch(request, env) {
const { pathname } = new URL(request.url); const { pathname } = new URL(request.url);
const path = pathname.replace(/\/$/, ""); const path = pathname.replace(/\/$/, "");
const fallback = new URL("/#install", request.url).toString(); const fallback = new URL("/#install", request.url).toString();
if (path === "/apk" || path === "/download") { if (path === "/apk" || path === "/download" || path === "/apk.sha256") {
// Order: live Gitea release → manual override → install section. const suffix = path === "/apk.sha256" ? ".sha256" : ".apk";
let target = null; let target = null;
try { try {
target = await latestApkUrl(env); target = asset(await latestRelease(env), suffix)?.browser_download_url;
} catch { } catch {
// Gitea unreachable — fall through rather than 500 on a download link. // Gitea unreachable — fall through rather than 500 on a download link.
} }
return redirect(target || env.DOWNLOAD_URL || fallback); const override = suffix === ".apk" ? env.DOWNLOAD_URL : null;
return redirect(target || override || fallback);
}
if (path === "/api/latest") {
let rel = null;
try {
rel = await latestRelease(env);
} catch {}
const apk = asset(rel, ".apk");
if (!rel || !apk) return json({ available: false }, 60);
return json(
{
available: true,
version: rel.tag_name,
published_at: rel.published_at,
apk: { name: apk.name, size: apk.size, url: apk.browser_download_url },
sha256: Boolean(asset(rel, ".sha256")),
},
300,
);
} }
const varName = STATIC_ROUTES[path]; const varName = STATIC_ROUTES[path];
+5 -5
View File
@@ -22,14 +22,14 @@
// its route fall back to the homepage's install section, so nothing 404s // its route fall back to the homepage's install section, so nothing 404s
// before the first release exists. // before the first release exists.
"vars": { "vars": {
// Gitea repo API base, e.g. "https://git.example.net/api/v1/repos/mram/echolot". // Gitea repo API base. The repo (or at least its releases) must be
// The repo (or at least its releases) must be publicly readable. // publicly readable for /apk and /api/latest to resolve.
"GITEA_REPO_API": "", "GITEA_REPO_API": "https://git.rambossek.at/api/v1/repos/EchoLot/echolot",
// Manual override / fallback while GITEA_REPO_API is unset or unreachable. // Manual override / fallback while GITEA_REPO_API is unreachable.
"DOWNLOAD_URL": "", "DOWNLOAD_URL": "",
// F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/ // F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/
"FDROID_URL": "", "FDROID_URL": "",
// Public source URL, the footer + /source target. // Public source URL, the footer + /source target.
"SOURCE_URL": "" "SOURCE_URL": "https://git.rambossek.at/EchoLot/echolot"
} }
} }