From 7d98a438665203ed793f139e834e1c436d4a7c6b Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 12:32:02 +0200 Subject: [PATCH] docs: close the VPN, v6, reserved-probe and signing items in the build log Co-Authored-By: Claude Opus 5 --- docs/build-status.md | 83 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/build-status.md b/docs/build-status.md index 6f782fa..5e53372 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -211,8 +211,8 @@ Two collection-loop gotchas found while driving the phone over USB: 2. ~~If `trace.errqueue_reachable` = PARTIAL, add a C-over-JNI errqueue shim.~~ **Retired** — SUPPORTED on both known devices; `traceroute.udp4` reads real hops via `Os.recvmsg` + `StructMsghdr` reflection, so no `:native` module is needed. -3. Start the Go server skeleton (enrollment + profile + sessions + UDP echo with observation - blocks + canary-DNS reference records) per probe-protocol.md. +3. ~~Start the Go server skeleton per probe-protocol.md.~~ **Shipped** — live on fmr since + v0.2.0 (2026-07-31); see the server sections below. 4. Fold confirmed capabilities into the production `core-probe` / `core-shizuku` modules. ## Production probe server — LIVE on dedicated VM "fmr" (2026-07-31) @@ -222,7 +222,7 @@ SSH only — verified untouched by the daemon (explicit multi-address binds, no Control: fmr-1:8443 (SPKI pin `zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg=`, verified externally over v4+v6). UDP data plane on all four service addresses :8442 — the second IP is the stun-5780 substrate. Daily randomized self-update timer installed (checksum-verified -against SHA256SUMS; signature verification still TODO before treating the source as untrusted). +against SHA256SUMS; signature verification landed 2026-08-02 — see "Release signing" below). Host config in `/etc/echolot-server.env`. SSH access for sessions: `ssh claude-echolot`. ## Server v0.3.0 — STUN + TCP echo + observations + actions (2026-07-31) @@ -1081,7 +1081,9 @@ StunProbe(serverHost = "fmr-1.echo-lot.app") so a user with no server of their own still sends DNS and STUN traffic to fmr without being told. For a tool that goes to this much trouble over what leaves the device, an undisclosed dependency on a third party's infrastructure is the wrong default. It should prefer the configured server, and be -explicit when there is none. **Open.** +explicit when there is none. **Closed** — both probes take the enrolled server from settings +(canary zone learned from the profile, cleared on re-enroll) and report themselves SKIPPED with +the reason when none is configured. ### v6.broken was a false positive waiting to happen (2026-08-01) A phone could not open `https://fmr.echo-lot.app` while loading the same server by IP literal @@ -1107,7 +1109,7 @@ when IPv6 works. The proper fix is corroboration: attempt a real IPv6 connection and only call it broken when that fails too. That needs a target, which runs into the hardcoded-reference-deployment issue already -open above. **Both remain open.** +open above. **Both closed 2026-08-02** — see "Corroborated IPv6 findings" below. ## Per-network probing is blocked while a VPN is up (2026-08-01) @@ -1122,7 +1124,8 @@ VPN connected. Worth deciding deliberately rather than discovering per report: - A user with a corporate VPN permanently on would get a green run that measured almost nothing. Options are to detect the VPN and say so plainly ("this network cannot be measured while a VPN is -active"), to measure the tunnel itself as the network under test, or both. Not yet decided. +active"), to measure the tunnel itself as the network under test, or both. **Decided and built +2026-08-02**: say so plainly, everywhere the run is read — see "Constrained runs" below. Related: `icmp.ping6` now records `attempted` alongside `ok` per network, because collapsing them made the app report "IPv6 is configured, but ICMPv6 gets no reply" about an interface it had never @@ -1151,7 +1154,8 @@ as it had been running, silently. It is now `systemctl disable --now echolot-adb with `systemctl enable --now`. Note what this implies: the guard covers this server's own listeners, and a stray process outside its config can still pollute a reserved address. A startup probe that *verifies* 80/443 are actually free on the reserved addresses would be a stronger guarantee than -checking our own configuration, and is not yet built. +checking our own configuration — built 2026-08-02 (`selftest.ReservedWebPortsFree`, fatal at +startup when anything is listening there). The admin UI and the ACME responder now take comma-separated addresses like every other listener; they were single-address, which is why the UI could only ever live on `::2`. It serves on `.150:443` and @@ -1216,3 +1220,68 @@ still points at it — not before. The rule from the naming change still binds: `fmr` may be a CNAME to exactly one host and never a multi-address record, because a pinned client that reaches a different key does not fail over. + +## Constrained runs: a VPN'd run now says so, everywhere (2026-08-02, app 0.2.1) + +The measurement schema gained a top-level `constraints` block (§3) and the app now fills it. +`ConstraintDetector` (core-probe) runs before any probe: one throwaway `Network.bindSocket()` per +non-VPN network, plus a transport check for an active VPN. The result lands in three places, and +all three are deliberate: + +- **`run.constraints`** — for machines. A server aggregating thousands of runs can now separate + "measured a healthy network" from "measured almost nothing through a tunnel"; the shapes were + identical before. +- **A `measurement.vpn_constrained` finding** — for the person reading this run, naming the + interfaces that went unmeasured. A constrained run with a quiet findings list still reads as + "nothing wrong here". +- **The §7.3 verdict** — `Verdicts.derive` takes the constraints and returns INCONCLUSIVE + outright for a per-network-blocked run, whatever the category lights say; the run screen shows + an amber "Measured through a VPN" banner above the verdict so INCONCLUSIVE reads as the OS + refusing, not the app failing. + +Detection is one bind per network rather than parsing per-test `attempted:false` breadcrumbs, so +it cannot drift when probe evidence formats change. + +## Corroborated IPv6 findings: v6.broken is back, with evidence (2026-08-02, app 0.2.1) + +The new `V6ConnectProbe` (test type `v6.brokenness`) attempts a real TCP connection over IPv6 to +the configured server's :443, per network that *claims* IPv6 (global address or v6 default +route) — IPv4-only networks are not attempted, since their failure is by design and would +manufacture the exact false positive this exists to kill. The finding derivation is now three-way: + +- ICMPv6 silent, TCP works → `v6.no_icmp_reply` at **high** confidence, retitled "ICMPv6 is + filtered here — IPv6 itself works" (still reported: filtered ICMPv6 breaks PMTUD). +- ICMPv6 silent, TCP fails too → **`v6.broken`** (high severity, reinstated in the registry + + findings-registry.md): two independent transports silent on a network advertising IPv6. +- No corroboration (no server configured, or the connect never got as far as sending) → the + two-explanation `v6.no_icmp_reply` at medium confidence, unchanged. + +Like STUN and the canary, the probe SKIPs honestly when no server is configured — corroboration +is a benefit of enrollment, not a reason to borrow fmr. + +## Server: reserved 80/443 verified against the OS, and signed releases (2026-08-02) + +**Reserved-address startup probe.** `serve()` now proves 80/443 are actually free on every +`ECHOLOT_RESERVED_ADDRS` address before starting: a throwaway bind per port +(`selftest.ReservedWebPortsFree`), fatal on EADDRINUSE with the offending address named — the +check `CheckReserved` cannot do, because a stray process outside our config (the adb-beacon +receiver on `0.0.0.0:443` was exactly that) is invisible to configuration checks. Bind errors +that are not "in use" (typo'd address, address not on this host) warn instead of refusing — +they are config problems, not pollution. + +**Release signing.** Self-update now trusts a signature, not a host. CI signs `SHA256SUMS` with +an ed25519 key (`relsign` package, `cmd/release-sign`) and the updater refuses any release whose +`SHA256SUMS.sig` is missing or does not verify against the public key baked into the binary +(`selfupdate.DefaultPublicKeyB64`; operators with their own pipeline override via +`ECHOLOT_SELF_UPDATE_PUBKEY`). The private key exists in exactly two places: the Gitea Actions +secret `RELEASE_SIGNING_KEY`, and the offline original on the dev PC at +`~/.echolot/release-signing-key`. It is deliberately NOT on fmr and NOT in the repo — a +compromised release host can withhold updates but no longer inject one. CI hard-fails when the +secret is missing (an unsigned release would strand every verifying server) and cross-checks the +signature against the key in the source it just built. + +**ACTION REQUIRED before the next `server-v*` tag:** add the Gitea repo secret +`RELEASE_SIGNING_KEY` (Settings → Actions → Secrets) with the contents of +`~/.echolot/release-signing-key` from the dev PC. Ordering is safe: the currently deployed +v0.3.x updater does not verify, so it will happily install the first signed release; every +release after that is verified.