Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Opus 5 43e1ba778a server: multi-address listeners, env-file config, self-update timer + checksums
server-test / test (push) Successful in 24s
server-release / image (push) Successful in 5s
server-release / release (push) Successful in 26s
- Comma-separated ECHOLOT_{CONTROL,UDP,TCP}_LISTEN; one listener/socket per
  address. Explicit binds matter on multi-IP hosts (a wildcard would also
  claim the SSH-only management address) and per-address UDP sockets are
  the substrate stun-5780 needs.
- systemd unit reads /etc/echolot-server.env (seeded once, never
  overwritten); --install-systemd with --self-update-api also installs a
  daily randomized update timer that try-restarts the service.
- selfupdate: SHA256SUMS verification is now mandatory before the atomic
  replace (integrity, not authenticity — signing still TODO).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:40:36 +02:00
Markus RambossekandClaude Opus 5 4de3064f71 reports: build-5 runs on both devices — new toolchain is behaviour-neutral
TB330FU (A15/SDK35) and CPH2747 (A16/SDK36) both re-run on the AGP 9.2 /
Gradle 9.6 / Kotlin 2.2.10 / JDK 25 / targetSdk 36 toolchain. Every probe on
both devices keeps the verdict it had on the previous build (tablet vs build-4,
phone vs build-3), so the upgrade changed no observed capability.

Dual-path Shizuku executor now confirmed on the current toolchain on both:
phone exec_path=UserService, tablet exec_path=newProcess fallback, both 7/7.

Collection-loop gotchas recorded in build-status.md, found while driving the
phone over USB:

- icmp.ping6's verdict is topology-dependent, not build-dependent. A run with
  only 2 networks up reported UNSUPPORTED; with cellular present it is the
  expected "echo reply on cellular only". Always read it against
  link.snapshot's network_count from the same report.
- The Shizuku permission dialog times out after 30 s and the probe logs
  "permission not granted" at ~30027 ms. Grant it before starting a run.
- uiautomator dump can report a stale idle state right after a tap; the
  exported JSON's durationMs values are the ground truth for completion.

CLAUDE.md's collection loop is updated — over USB the run is fully driveable
via am start / input tap / run-as cat, no manual export needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:35:53 +02:00
Markus RambossekandClaude Opus 5 edcad3e539 prober: AGP 9.2 / Gradle 9.6 / Kotlin 2.2.10, targetSdk 36, build 5
Lifts the build off the JDK-21 ceiling — the old AGP 8.7.3 pin could not run
on JDK 25. Two AGP-9 migration edits were required:

- AGP 9 ships built-in Kotlin support, so applying org.jetbrains.kotlin.android
  alongside it fails with "extension with name kotlin already registered";
  the alias is dropped (kotlin.compose / kotlin.serialization stay, they are
  separate compiler plugins).
- kotlinOptions { jvmTarget } came from that plugin and no longer resolves;
  AGP derives jvmTarget from compileOptions instead.

compileSdk/targetSdk 35 -> 36, since Android Studio ships API 36.1 by default
and installing 35 was avoidable churn. versionCode 5 so the reports from this
toolchain are attributable (build 4 was already archived from the old one).

Also fixes a missing comma that left .claude/settings.json invalid JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:34:54 +02:00
mrambossekandClaude Opus 5 cae3c00990 server: document deployment requirements — no proxy, no 80/443, and why
server-test / test (push) Successful in 24s
Ports table, host-IP/second-IP/canary-zone requirements, and the two
protocol properties (SPKI pinning, observed-source fidelity) that a
reverse proxy would break. Coexists with traefik by never touching its
ports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:54:52 +02:00
mrambossekandClaude Opus 5 b7658a6777 server: selfupdate must normalize namespaced tags; build-status: pipeline green
server-test / test (push) Successful in 24s
Release tags are server-v1.2.3 but binaries are stamped v1.2.3 — the raw
comparison would re-download the current version on every check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:38:08 +02:00
13 changed files with 669 additions and 69 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"WebFetch(domain:open-vsx.org)" "WebFetch(domain:open-vsx.org)",
"WebFetch(domain:github.com)" "WebFetch(domain:github.com)"
] ]
} }
+13 -9
View File
@@ -63,23 +63,27 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
- **Prefer USB for adb.** Wireless debugging dies constantly: probe runs churn the wifi the - **Prefer USB for adb.** Wireless debugging dies constantly: probe runs churn the wifi the
debug link rides on, the Lenovo tablet's ZUI power management kills the listener anyway, and debug link rides on, the Lenovo tablet's ZUI power management kills the listener anyway, and
the port rotates on every restart. Deploys over wireless work between runs; never drive a the port rotates on every restart. Never drive a probe run over *wireless* adb.
probe run over adb. - Collection loop over USB, fully driveable by Claude: install → `am start` → tap "Run all
- Collection loop that works: install over adb → the **user** runs the probes and exports the probes" → poll `uiautomator dump` until the button label returns to "Run all probes" → tap
JSON manually → archive it under `echolot-prober/reports/` and record findings in "Export JSON" → the report lands in `cache/reports/` and comes back via
`docs/build-status.md`. `adb shell run-as app.echo_lot.prober cat …` (no need to drive the share sheet; dismiss it
with BACK). Archive under `echolot-prober/reports/`, record findings in `docs/build-status.md`.
- Bash-tool `adb shell` calls with absolute device paths get mangled by Git Bash path
conversion (`/data/…``C:/Program Files/Git/data/…`); use the PowerShell tool for those.
- Bump `versionCode` on every deployed prober change — it shows on screen and as `proberBuild` - Bump `versionCode` on every deployed prober change — it shows on screen and as `proberBuild`
in the report; that's how a report is matched to a build. in the report; that's how a report is matched to a build.
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works; - Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
it. Full history in build-status.md. it. Full history in build-status.md.
- Gradle needs JDK 1721 (`JAVA_HOME`); a system JDK 25 breaks AGP 8.7. On machines with - As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
Android Studio, its `jbr` directory works. AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
Android Studio, its `jbr` directory works as `JAVA_HOME`.
## Likely next steps ## Likely next steps
1. Run on physical devices; collect JSON reports across Android versions/vendors. 1. Run on physical devices; collect JSON reports across Android versions/vendors.
2. If `trace.errqueue_reachable` is PARTIAL, add the C-over-JNI errqueue shim (recvmsg + cmsg parse) 2. ~~C-over-JNI errqueue shim~~ — not needed; `trace.errqueue_reachable` and `traceroute.udp4`
as a `:native` module and a real `traceroute.udp4` probe. are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production 3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
`core-probe` / `core-shizuku` modules. `core-probe` / `core-shizuku` modules.
+48 -5
View File
@@ -159,15 +159,58 @@ Repo pushed to git.rambossek.at/EchoLot/echolot (ssh :2222). Actions runner work
paths+tags triggers never fire (split into test-server.yml / build-server.yml), and a greedy paths+tags triggers never fire (split into test-server.yml / build-server.yml), and a greedy
`sed` grabbed a nested user id instead of the release id → uploads 404ed (first-match grep now; `sed` grabbed a nested user id instead of the release id → uploads 404ed (first-match grep now;
release creation also falls back to GET-by-tag on re-runs). Binaries verified downloadable → release creation also falls back to GET-by-tag on re-runs). Binaries verified downloadable →
`--self-update` has its artifact source. **Still open: the `image` job** — needs a `--self-update` has its artifact source. **Image job green too** on the dedicated
docker-capable runner (GITHUB_SERVER_URL inside the runner is the internal `http://app:3000`; `compilesau-echolot` runner (label `echolot`): needed a PAT with package read/write as the
set `ECHOLOT_REGISTRY_HOST=git.rambossek.at` as a repo variable/env when enabling it). `REGISTRY_TOKEN` repo secret — the built-in Actions token is rejected by the container
registry. `git.rambossek.at/echolot/echolot-server:{v0.1.0,latest}` pulls anonymously
(3.9 MB distroless, verified). Full chain proven: tag → tests → binaries → release assets →
registry image → anonymous pull. Note the tag convention: image tags carry the bare version
(`v0.1.0`), release tags the namespaced one (`server-v0.1.0`); selfupdate normalizes.
## Toolchain upgrade + build-5 tablet re-run (2026-07-30)
Moved to the laptop (USB adb — far more stable than wireless, as expected). Upgraded the build
off the JDK-21 ceiling: **AGP 8.7.3 → 9.2.0, Gradle 8.14.3 → 9.6.0, Kotlin 2.0.21 → 2.2.10**,
which lets the toolchain run on JDK 25. Two AGP-9 migration edits were required:
- AGP 9 has **built-in Kotlin support**, so applying `org.jetbrains.kotlin.android` alongside it
fails with "extension with name 'kotlin' already registered" — the alias must be dropped
(`kotlin.compose` / `kotlin.serialization` stay; they are separate compiler plugins).
- The `kotlinOptions { jvmTarget }` block came from that plugin and no longer resolves; AGP
derives jvmTarget from `compileOptions` instead.
`compileSdk`/`targetSdk` 35 → 36, because Android Studio only ships API 36.1 by default and
installing 35 was avoidable churn. **Build-5 re-run on the TB330FU is verdict-identical to
build-4 across all 10 probes** (`…-build5.json`) — the toolchain jump and targetSdk 36 are
behaviour-neutral on this device, including the `newProcess` Shizuku fallback (still 7/7,
`exec_path: newProcess fallback`, UserService still never binds).
Also of note: `traceroute.udp4` is SUPPORTED here (6 hops via errqueue, "no native shim needed"),
which is what retires next-step 2 below — the JNI shim is not required on either known device.
**OnePlus 15 build-5 re-run (`CPH2747-android16-sdk36-build5.json`): all 10 SUPPORTED, identical
to build-3.** `exec_path: UserService` on the phone vs `newProcess fallback` on the tablet, both
7/7 — the dual-path executor requirement is now confirmed on the current toolchain on both
devices. Phone is on Android 16 / SDK 36, i.e. `targetSdk = 36` is also exercised natively there.
Two collection-loop gotchas found while driving the phone over USB:
- **`icmp.ping6`'s verdict is topology-dependent, not build-dependent.** A first run with Shizuku
stopped and only 2 networks up reported UNSUPPORTED; with cellular+wifi present it is the
expected SUPPORTED / "echo reply on cellular only — default network has no v6 path". Do not
read a ping6 verdict without checking `link.snapshot`'s `network_count` in the same report.
- **The Shizuku permission dialog times out after 30 s** (`shizuku.command_battery` logs
`Shizuku permission not granted` at ~30027 ms). Screenshot→read→tap round-trips can exceed
that. Grant the permission *before* starting a run, or accept that the first run after a fresh
install burns the Shizuku probe; the grant is persistent, so the next run is clean.
- The phone auto-rotates; tap coordinates must be recomputed per orientation, and `uiautomator
dump` can report a stale idle state right after a tap — trust the exported JSON's `durationMs`
values as ground truth for whether a run really completed, not the button label.
## Next steps ## Next steps
1. Build locally, run on several physical devices (varied Android versions/vendors), collect the 1. Build locally, run on several physical devices (varied Android versions/vendors), collect the
JSON reports — especially the real per-device Shizuku dump formats. JSON reports — especially the real per-device Shizuku dump formats.
2. If `trace.errqueue_reachable` = PARTIAL, add a C-over-JNI errqueue shim (`recvmsg`+cmsg parse) 2. ~~If `trace.errqueue_reachable` = PARTIAL, add a C-over-JNI errqueue shim.~~ **Retired** —
as a `:native` module and a real `traceroute.udp4` probe. 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 3. Start the Go server skeleton (enrollment + profile + sessions + UDP echo with observation
blocks + canary-DNS reference records) per probe-protocol.md. blocks + canary-DNS reference records) per probe-protocol.md.
4. Fold confirmed capabilities into the production `core-probe` / `core-shizuku` modules. 4. Fold confirmed capabilities into the production `core-probe` / `core-shizuku` modules.
+3 -7
View File
@@ -1,21 +1,20 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
} }
android { android {
namespace = "app.echo_lot.prober" namespace = "app.echo_lot.prober"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "app.echo_lot.prober" applicationId = "app.echo_lot.prober"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
// Bump versionCode on EVERY deployed change — it is shown on screen and lands in the // Bump versionCode on EVERY deployed change — it is shown on screen and lands in the
// JSON report as proberBuild, so a report is attributable to an exact prober build. // JSON report as proberBuild, so a report is attributable to an exact prober build.
versionCode = 4 versionCode = 5
versionName = "0.1.0" versionName = "0.1.0"
} }
@@ -28,9 +27,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
aidl = true aidl = true
+2 -2
View File
@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.7.3" agp = "9.2.0"
kotlin = "2.0.21" kotlin = "2.2.10"
coreKtx = "1.13.1" coreKtx = "1.13.1"
lifecycle = "2.8.7" lifecycle = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
@@ -0,0 +1,191 @@
{
"schema": "echolot/prober-report",
"schemaVersion": "0.1.0",
"proberBuild": 5,
"device": {
"manufacturer": "OnePlus",
"model": "CPH2747",
"androidSdk": 36,
"androidRelease": "16"
},
"results": [
{
"id": "link.snapshot",
"title": "LinkProperties snapshot (all active networks)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "3 active network(s) read",
"evidence": {
"network_count": "3",
"net0.cellular.iface": "rmnet_data4",
"net0.cellular.mtu": "1500",
"net0.cellular.addrs": "2001:4bb8:417:bd78:e4fe:8cff:febe:ca8c/64",
"net0.cellular.dns": "fda1:3fb1:0:8:0:10:0:101, fda1:3fb1:0:8:0:10:0:100",
"net0.cellular.routes": "::/0 -> fe80::246f:12be:21ef:1b54 rmnet_data4 mtu 1500 | 2001:4bb8:417:bd78::/64 -> :: rmnet_data4 mtu 0",
"net0.cellular.domains": "",
"net0.cellular.nat64": "none",
"net0.cellular.private_dns": "off/opportunistic",
"net1.wifi.iface": "wlan0",
"net1.wifi.mtu": "0",
"net1.wifi.addrs": "fe80::bcf6:edff:fe67:b139/64, 10.13.102.122/24",
"net1.wifi.dns": "10.13.102.1",
"net1.wifi.routes": "fe80::/64 -> :: wlan0 mtu 0 | ::/0 -> fe80::7a9a:18ff:fe54:b8f9 wlan0 mtu 0 | 10.13.102.0/24 -> 0.0.0.0 wlan0 mtu 0 | 0.0.0.0/0 -> 10.13.102.1 wlan0 mtu 0",
"net1.wifi.domains": "hudelist.local",
"net1.wifi.nat64": "none",
"net1.wifi.private_dns": "off/opportunistic",
"net2.cellular.iface": "rmnet_data2",
"net2.cellular.mtu": "1500",
"net2.cellular.addrs": "10.73.62.113/30, 2001:4bb8:2fb:fe4c:f028:16ff:fee4:774d/64",
"net2.cellular.dns": "10.88.152.122, 10.88.152.123, fda1:3fb1:0:8:0:10:0:102, fda1:3fb1:0:8:0:10:0:103",
"net2.cellular.routes": "0.0.0.0/0 -> 10.73.62.114 rmnet_data2 mtu 1500 | ::/0 -> fe80::9419:9179:7ab7:1a1d rmnet_data2 mtu 1500 | 10.73.62.112/30 -> 0.0.0.0 rmnet_data2 mtu 0 | 2001:4bb8:2fb:fe4c::/64 -> :: rmnet_data2 mtu 0",
"net2.cellular.domains": "",
"net2.cellular.nat64": "none",
"net2.cellular.private_dns": "off/opportunistic"
},
"durationMs": 2
},
{
"id": "icmp.ping4",
"title": "ICMPv4 echo (unprivileged datagram socket)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Echo reply on the default network",
"evidence": {
"default": "reply type=0 rtt_ms=79.3 bytes=22 target=1.1.1.1",
"net.cellular": "reply type=0 rtt_ms=180.5 bytes=22 target=1.1.1.1",
"net.wifi": "reply type=0 rtt_ms=39.7 bytes=22 target=1.1.1.1"
},
"durationMs": 304
},
{
"id": "icmp.ping6",
"title": "ICMPv6 echo (unprivileged datagram socket)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Echo reply on cellular only — default network has no v6 path",
"evidence": {
"default": "error: recvfrom failed: EAGAIN (Try again)",
"net.cellular": "reply type=129 rtt_ms=53.8 bytes=22 target=2606:4700:4700::1111",
"net.wifi": "error: recvfrom failed: EAGAIN (Try again)"
},
"durationMs": 6308
},
{
"id": "sockopt.matrix",
"title": "Socket options: TTL, RECVERR, MTU_DISCOVER (DF)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "4/4 core sockopts accepted; IP_MTU read=false",
"evidence": {
"IP_TTL": "accepted (ttl=5)",
"IP_TOS/DSCP": "accepted (EF/46)",
"IP_RECVERR": "accepted",
"IP_MTU_DISCOVER=PROBE": "accepted (DF set)",
"IP_MTU(read)": "unreadable: android.system.Os.getsockoptInt [class java.io.FileDescriptor, int, int]"
},
"durationMs": 2
},
{
"id": "trace.errqueue_reachable",
"title": "Traceroute via IP_RECVERR + MSG_ERRQUEUE",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Errqueue path fully reachable from Os API",
"evidence": {
"IP_RECVERR": "accepted",
"IP_TTL=1": "accepted",
"StructMsghdr": "true",
"Os.recvmsg": "true"
},
"durationMs": 1
},
{
"id": "traceroute.udp4",
"title": "UDP traceroute via MSG_ERRQUEUE (no root, no JNI)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "6 hop(s) read via errqueue — no native shim needed",
"evidence": {
"recvmsg_api": "StructMsghdr + Os.recvmsg via reflection",
"target": "1.1.1.1",
"hop.1": "10.13.102.1 icmp type=11 origin=2 ~41 ms",
"hop.2": "178.191.103.254 icmp type=11 origin=2 ~41 ms",
"hop.3": "195.3.76.32 icmp type=11 origin=2 ~41 ms",
"hop.4": "195.3.64.153 icmp type=11 origin=2 ~41 ms",
"hop.5": "172.68.48.30 icmp type=11 origin=2 ~41 ms",
"hop.6": "172.68.48.14 icmp type=11 origin=2 ~41 ms"
},
"durationMs": 248
},
{
"id": "multinetwork.request_and_bind",
"title": "Concurrent per-network binding (Wi-Fi / cellular / ethernet)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "2 transport(s) acquired and bound (only currently-present links can bind)",
"evidence": {
"wifi": "network acquired; bindSocket=ok; downKbps=27368",
"cellular": "network acquired; bindSocket=ok; downKbps=13231",
"ethernet": "no network within 4s"
},
"durationMs": 4013
},
{
"id": "local.mdns_discover",
"title": "Multicast reception (MulticastLock + mDNS/NSD)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "mDNS discovery ran; 4 service(s)/type(s) seen across 3 queries",
"evidence": {
"multicast_lock": "acquired",
"meta.started": "true",
"meta.found": "0",
"meta.type": "_services._dns-sd._udp.",
"http.started": "true",
"http.found": "4",
"http.names": "Magic 2 LAN 1-1, Magic 2 LAN triple #5, Magic 2 LAN triple #4, EPSON WF-7840 Series",
"http.type": "_http._tcp.",
"googlecast.started": "true",
"googlecast.found": "0",
"googlecast.type": "_googlecast._tcp."
},
"durationMs": 10005
},
{
"id": "peer.ble_advertise",
"title": "BLE peripheral advertising (peer-mode control channel)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Advertising start: success",
"evidence": {
"FEATURE_BLUETOOTH_LE": "true",
"adapter_enabled": "true",
"multi_advertisement_supported": "true",
"startAdvertising": "success"
},
"durationMs": 29
},
{
"id": "shizuku.command_battery",
"title": "Shizuku shell tier (ip neigh / route / dumpsys network_stack)",
"tier": "SHIZUKU",
"verdict": "SUPPORTED",
"summary": "Shizuku runs as shell(2000); 7/7 commands returned data",
"evidence": {
"binder_alive": "true",
"version": "13",
"runs_as": "shell(2000)",
"permission": "true",
"exec_path": "UserService",
"id": "uid=2000\nuid=2000(shell) gid=2000(shell) groups=2000(shell),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),1078(ext_data_rw),1079(ext_obb_rw),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid),3012(readtracefs) context=u:r:shell:s0",
"ip_neigh": "uid=2000\n10.13.102.111 dev wlan0 FAILED\n10.13.102.50 dev wlan0 lladdr 50:57:9c:4f:7a:3c STALE\n10.13.102.31 dev wlan0 lladdr 98:5f:d3:f6:f1:75 STALE\n10.13.102.116 dev wlan0 lladdr 0c:08:b4:03:68:0e STALE\n10.13.102.120 dev wlan0 lladdr 0e:d8:14:58:6c:8b STALE\n10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE\n10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE\n10.13.102.21 dev wlan0 lladdr c8:7f:54:01:94:7c STALE\nfe80::babe:f4ff:febc:caf9 dev wlan0 lladdr b8:be:f4:bc:ca:f9 REACHABLE\nfe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router STALE\nfe80::babe:f4ff:febc:cacf dev wlan0 lladdr b8:be:f4:bc:ca:cf REACHABLE\nfe80::babe:f4ff:fec2:bf14 dev wlan0 lladdr b8:be:f4:c2:bf:14 REACHABLE",
"ip6_route": "uid=2000\nfe80::/64 dev wlan0 table 1028 proto kernel metric 256 pref medium\nfe80::/64 dev wlan0 table 1028 proto static metric 1024 pref medium\ndefault via fe80::7a9a:18ff:fe54:b8f9 dev wlan0 table 1028 proto ra metric 1024 expires 1269sec pref medium\nfe80::/64 dev vgate0 table 1031 proto kernel metric 256 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1032 proto static metric 1024 pref medium\nfe80::/64 dev rmnet_data4 table 1032 proto kernel metric 256 pref medium\ndefault via fe80::246f:12be:21ef:1b54 dev rmnet_data4 table 1032 proto ra metric 1024 expires 64373sec hoplimit 255 pref medium\n2001:4bb8:2fb:fe4c::/64 dev rmnet_data2 table 1000000022 proto static metric 1024 pref medium\nfe80::/64 dev wlan0 table 1000000028 proto static metric 1024 pref medium\n2001:4bb8:417:bd78::/64 dev rmnet_data4 table 1000000032 proto static metric 1024 pref medium\nfe80::/64 dev dummy0 table 1002 proto kernel metric 256 pref medium\ndefault dev dummy0 table 1002 proto static metric 1024 pref medium\nfe80::/64 dev ifb0 table 1003 proto kernel metric 256 pref medium\nfe80::/64 dev ifb1 table 1004 proto kerne",
"ip_addr": "uid=2000\n1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000\n link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00\n inet 127.0.0.1/8 scope host lo\n valid_lft forever preferred_lft forever\n inet6 ::1/128 scope host \n valid_lft forever preferred_lft forever\n2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000\n link/ether be:3d:e2:93:78:b9 brd ff:ff:ff:ff:ff:ff\n inet6 fe80::bc3d:e2ff:fe93:78b9/64 scope link \n valid_lft forever preferred_lft forever\n3: ifb0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc htb state UNKNOWN group default qlen 1000\n link/ether ba:6e:46:b5:3d:bb brd ff:ff:ff:ff:ff:ff\n inet6 fe80::b86e:46ff:feb5:3dbb/64 scope link \n valid_lft forever preferred_lft forever\n4: ifb1: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc htb state UNKNOWN group default qlen 1000\n link/ether d6:2a:e2:f5:93:8f brd ff:ff:ff:ff:ff:ff\n inet6 fe80::d42a:e2ff:fef5:938f/64 scope link \n valid_lft forever preferred_lft forever\n5: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000\n link/ipip 0.0.0.0 brd 0.0.0.0\n6: gre0@NONE: <NO",
"ip_monitor": "uid=2000",
"dhcp_log": "uid=2000\nRecently active IpClient logs:\nIpClient.wlan0\n IpClient.wlan0 APF dump:\n Capabilities: { apfVersionSupported: 6000, maximumApfProgramSize: 4096 }\n InstallableProgramSizeClamp: 2147483647\n--\n IpClient.wlan0 current ProvisioningConfiguration:\n ProvisioningConfiguration{mUniqueEui64AddressesOnly: false, mEnablePreconnection: false, mUsingMultinetworkPolicyTracker: true, mUsingIpReachabilityMonitor: true, mRequestedPreDhcpActionMs: 18000, mInitialConfig: null, mStaticIpConfig: null, mApfCapabilities: ApfCapabilities{version: 6000, maxSize: 4096, format: 1}, mProvisioningTimeoutMs: 18000, mIPv6AddrGenMode: 0, mNetwork: 171, mDisplayName: \"hudeWLAN\", mCreatorUid:1000, mScanResultInfo: SSID: hudeWLAN, BSSID: 24:5a:4c:5f:58:c5, Information Elements: {[ID: 0, [104, 117, 100, 101, 87, 76, 65, 78]][ID: 1, [-126, -124, -117, -106, 18, 36, 72, 108]][ID: 3, [6]][ID: 42, [0]][ID: 50, [12, 24, 48, 96]][ID: 45, [-83, 1, 23, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, 4, -121, 25, 0]][ID: 61, [6, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]][ID: 48, [1, 0, 0, 15, -84, 4, 1, 0, 0, 15, -84, 4, 1, 0, 0, 15, -84, 2, 0, 0]][ID: 127, [0, 0, 8, -128, ",
"wifi_dump": "uid=2000\n rec[7]: time=07-30 16:23:27.779 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=on v4 v4r v4dns v6r\n rec[8]: time=07-30 16:23:27.784 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=on DhcpResultsParcelable{baseConfiguration: IP address 10.13.102.122/24 Gateway 10.13.102.1 DNS servers: [ 10.13.102.1 ] Domains hudelist.local, leaseDuration: 600, mtu: 0, serverAddress: 10.13.102.1, vendorInfo: null, serverHostName: , captivePortalApiUrl: null}\n rec[9]: time=07-30 16:23:27.791 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_UPDATE_LINKPROPERTIES screen=on 17 0 v4 v4r v4dns v6r\n rec[10]: time=07-30 16:23:47.971 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_SCREEN_STATE_CHANGED screen=off 0 0\n--\n rec[31]: time=07-30 16:28:27.831 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=off v4 v4r v4dns v6r\n rec[32]: time=07-30 16:28:27.835 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=off DhcpResultsParcelable{baseConfiguration: IP address 10.13.1"
},
"durationMs": 6000
}
]
}
@@ -0,0 +1,173 @@
{
"schema": "echolot/prober-report",
"schemaVersion": "0.1.0",
"proberBuild": 5,
"device": {
"manufacturer": "LENOVO",
"model": "TB330FU",
"androidSdk": 35,
"androidRelease": "15"
},
"results": [
{
"id": "link.snapshot",
"title": "LinkProperties snapshot (all active networks)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "1 active network(s) read",
"evidence": {
"network_count": "1",
"net0.wifi.iface": "wlan0",
"net0.wifi.mtu": "0",
"net0.wifi.addrs": "fe80::416:b9ff:feac:5b65/64, 10.13.102.125/24",
"net0.wifi.dns": "10.13.102.1",
"net0.wifi.routes": "fe80::/64 -> :: wlan0 mtu 0 | ::/0 -> fe80::7a9a:18ff:fe54:b8f9 wlan0 mtu 0 | 10.13.102.0/24 -> 0.0.0.0 wlan0 mtu 0 | 0.0.0.0/0 -> 10.13.102.1 wlan0 mtu 0",
"net0.wifi.domains": "hudelist.local",
"net0.wifi.nat64": "none",
"net0.wifi.private_dns": "off/opportunistic"
},
"durationMs": 15
},
{
"id": "icmp.ping4",
"title": "ICMPv4 echo (unprivileged datagram socket)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Echo reply on the default network",
"evidence": {
"default": "reply type=0 rtt_ms=104.5 bytes=22 target=1.1.1.1",
"net.wifi": "reply type=0 rtt_ms=24.2 bytes=22 target=1.1.1.1"
},
"durationMs": 135
},
{
"id": "icmp.ping6",
"title": "ICMPv6 echo (unprivileged datagram socket)",
"tier": "APP",
"verdict": "ERROR",
"summary": "No echo reply on any of 2 attempt(s)",
"evidence": {
"default": "error: recvfrom failed: EAGAIN (Try again)",
"net.wifi": "error: recvfrom failed: EAGAIN (Try again)"
},
"durationMs": 6125
},
{
"id": "sockopt.matrix",
"title": "Socket options: TTL, RECVERR, MTU_DISCOVER (DF)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "4/4 core sockopts accepted; IP_MTU read=false",
"evidence": {
"IP_TTL": "accepted (ttl=5)",
"IP_TOS/DSCP": "accepted (EF/46)",
"IP_RECVERR": "accepted",
"IP_MTU_DISCOVER=PROBE": "accepted (DF set)",
"IP_MTU(read)": "unreadable: android.system.Os.getsockoptInt [class java.io.FileDescriptor, int, int]"
},
"durationMs": 6
},
{
"id": "trace.errqueue_reachable",
"title": "Traceroute via IP_RECVERR + MSG_ERRQUEUE",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Errqueue path fully reachable from Os API",
"evidence": {
"IP_RECVERR": "accepted",
"IP_TTL=1": "accepted",
"StructMsghdr": "true",
"Os.recvmsg": "true"
},
"durationMs": 5
},
{
"id": "traceroute.udp4",
"title": "UDP traceroute via MSG_ERRQUEUE (no root, no JNI)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "5 hop(s) read via errqueue — no native shim needed",
"evidence": {
"recvmsg_api": "StructMsghdr + Os.recvmsg via reflection",
"target": "1.1.1.1",
"hop.1": "10.13.102.1 icmp type=11 origin=2 ~44 ms",
"hop.2": "178.191.103.254 icmp type=11 origin=2 ~41 ms",
"hop.3": "195.3.76.32 icmp type=11 origin=2 ~44 ms",
"hop.4": "* (no errqueue event in 900 ms)",
"hop.5": "172.68.48.30 icmp type=11 origin=2 ~42 ms",
"hop.6": "172.68.48.14 icmp type=11 origin=2 ~42 ms"
},
"durationMs": 1140
},
{
"id": "multinetwork.request_and_bind",
"title": "Concurrent per-network binding (Wi-Fi / cellular / ethernet)",
"tier": "APP",
"verdict": "PARTIAL",
"summary": "1 transport(s) acquired and bound (only currently-present links can bind)",
"evidence": {
"wifi": "network acquired; bindSocket=ok; downKbps=30000",
"cellular": "no network within 4s",
"ethernet": "no network within 4s"
},
"durationMs": 8036
},
{
"id": "local.mdns_discover",
"title": "Multicast reception (MulticastLock + mDNS/NSD)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "mDNS discovery ran; 4 service(s)/type(s) seen across 3 queries",
"evidence": {
"multicast_lock": "acquired",
"meta.started": "true",
"meta.found": "0",
"meta.type": "_services._dns-sd._udp.",
"http.started": "true",
"http.found": "4",
"http.names": "Magic 2 LAN 1-1, Magic 2 LAN triple #5, Magic 2 LAN triple #4, EPSON WF-7840 Series",
"http.type": "_http._tcp.",
"googlecast.started": "true",
"googlecast.found": "0",
"googlecast.type": "_googlecast._tcp."
},
"durationMs": 10042
},
{
"id": "peer.ble_advertise",
"title": "BLE peripheral advertising (peer-mode control channel)",
"tier": "APP",
"verdict": "SUPPORTED",
"summary": "Advertising start: success",
"evidence": {
"FEATURE_BLUETOOTH_LE": "true",
"adapter_enabled": "true",
"multi_advertisement_supported": "true",
"startAdvertising": "success"
},
"durationMs": 74
},
{
"id": "shizuku.command_battery",
"title": "Shizuku shell tier (ip neigh / route / dumpsys network_stack)",
"tier": "SHIZUKU",
"verdict": "SUPPORTED",
"summary": "Shizuku runs as shell(2000); 6/7 commands returned data",
"evidence": {
"binder_alive": "true",
"version": "13",
"runs_as": "shell(2000)",
"permission": "true",
"exec_path": "newProcess fallback (UserService bind failed)",
"id": "uid=2000(shell) gid=2000(shell) groups=2000(shell),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),1078(ext_data_rw),1079(ext_obb_rw),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid),3012(readtracefs) context=u:r:shell:s0",
"ip_neigh": "10.13.102.21 dev wlan0 lladdr c8:7f:54:01:94:7c STALE\n10.13.102.64 dev wlan0 lladdr b8:be:f4:c2:bf:14 STALE\n10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 STALE\n10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 STALE\n10.13.102.79 dev wlan0 lladdr 02:11:32:25:63:bb STALE\nfe80::babe:f4ff:febc:cacf dev wlan0 lladdr b8:be:f4:bc:ca:cf STALE\nfe80::7a9a:18ff:fe54:b8f9 dev wlan0 lladdr 78:9a:18:54:b8:f9 router STALE\nfe80::babe:f4ff:fec2:bf14 dev wlan0 lladdr b8:be:f4:c2:bf:14 STALE\nfe80::babe:f4ff:febc:caf9 dev wlan0 lladdr b8:be:f4:bc:ca:f9 STALE",
"ip6_route": "fe80::/64 dev wlan0 table 1000000015 proto static metric 1024 pref medium\nfe80::/64 dev dummy0 table 1002 proto kernel metric 256 pref medium\ndefault dev dummy0 table 1002 proto static metric 1024 pref medium\nfe80::/64 dev wlan0 table 1015 proto kernel metric 256 pref medium\nfe80::/64 dev wlan0 table 1015 proto static metric 1024 pref medium\ndefault via fe80::7a9a:18ff:fe54:b8f9 dev wlan0 table 1015 proto ra metric 1024 expires 1622sec pref medium\nlocal ::1 dev lo table local proto kernel metric 0 pref medium\nlocal fe80::416:b9ff:feac:5b65 dev wlan0 table local proto kernel metric 0 pref medium\nlocal fe80::1450:43ff:feec:93c4 dev dummy0 table local proto kernel metric 0 pref medium\nmulticast ff00::/8 dev dummy0 table local proto kernel metric 256 pref medium\nmulticast ff00::/8 dev wlan0 table local proto kernel metric 256 pref medium",
"ip_addr": "1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000\n link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00\n inet 127.0.0.1/8 scope host lo\n valid_lft forever preferred_lft forever\n inet6 ::1/128 scope host \n valid_lft forever preferred_lft forever\n2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000\n link/ether 16:50:43:ec:93:c4 brd ff:ff:ff:ff:ff:ff\n inet6 fe80::1450:43ff:feec:93c4/64 scope link \n valid_lft forever preferred_lft forever\n3: ifb0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 32\n link/ether f6:d4:d4:9b:51:9c brd ff:ff:ff:ff:ff:ff\n4: ifb1: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default qlen 32\n link/ether fe:16:ea:60:a2:d1 brd ff:ff:ff:ff:ff:ff\n5: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000\n link/ipip 0.0.0.0 brd 0.0.0.0\n6: gre0@NONE: <NOARP> mtu 1476 qdisc noop state DOWN group default qlen 1000\n link/gre 0.0.0.0 brd 0.0.0.0\n7: gretap0@NONE: <BROADCAST,MULTICAST> mtu 1462 qdisc noop state DOWN group default qlen 1000\n link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:f",
"ip_monitor": "EXEC_TIMEOUT(newProcess)",
"dhcp_log": "Recently active IpClient logs:\nIpClient.wlan0\n IpClient.wlan0 APF dump:\n No active ApfFilter; IpClient not yet started.\n \n IpClient.wlan0 current ProvisioningConfiguration:\n N/A\n \n IpClient.wlan0 StateMachine dump:\n 2026-07-30T14:15:44.904384 - CMD_UPDATE_L2INFORMATION wlan0/15 0 0 Layer2InformationParcelable{l2Key: null, cluster: null, bssid: null} [rcvd_in=null, proc_in=null]\n 2026-07-30T14:15:45.903681 - CMD_UPDATE_L2INFORMATION wlan0/15 0 0 Layer2InformationParcelable{l2Key: We37ae5a065df5f7e, cluster: G8ead16cc1945078e, bssid: 24:5a:4c:5f:6d:2e} [rcvd_in=null, proc_in=null]\n 2026-07-30T14:15:45.917119 - CMD_STOP wlan0/15 1 0 null [rcvd_in=StoppedState, proc_in=IpClient$StoppedState]\n 2026-07-30T14:15:45.917386 - CMD_UPDATE_HTTP_PROXY wlan0/15 0 0 null [rcvd_in=StoppedState, proc_in=IpClient$StoppedState]\n 2026-07-30T14:15:45.921937 - CMD_UPDATE_TCP_BUFFER_SIZES wlan0/15 0 0 1730560,3461120,6922240,524288,1048576,4525824 [rcvd_in=StoppedState, proc_in=IpClient$StoppedState]\n 2026-07-30T14:15:45.922243 - INVOKE getInterfaceVersion\n 2026-07-30T14:15:45.923124 - CMD_START wlan0/15 0 0 ProvisioningConfiguration{mUniqueEui64AddressesOnly: false, mEnab",
"wifi_dump": "rec[2]: time=07-30 15:44:09.909 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=off v4 v4r v4dns v6r\n rec[3]: time=07-30 15:44:09.915 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=off DhcpResultsParcelable{baseConfiguration: IP address 10.13.102.125/24 Gateway 10.13.102.1 DNS servers: [ 10.13.102.1 ] Domains hudelist.local, leaseDuration: 600, mtu: 0, serverAddress: 10.13.102.1, vendorInfo: null, serverHostName: , captivePortalApiUrl: null}\n rec[4]: time=07-30 15:44:09.916 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_UPDATE_LINKPROPERTIES screen=off 3 0 v4 v4r v4dns v6r\n rec[5]: time=07-30 15:48:27.650 processed=ConnectableState org=L3ConnectedState dest=<null> what=CMD_INSTALL_PACKET_FILTER screen=off len=435\n--\n rec[13]: time=07-30 15:49:09.976 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_POST_DHCP_ACTION screen=on v4 v4r v4dns v6r\n rec[14]: time=07-30 15:49:10.006 processed=L2ConnectedState org=L3ConnectedState dest=<null> what=CMD_IPV4_PROVISIONING_SUCCESS screen=on DhcpResultsParcelable{baseConfiguration: IP address 10.13.102.125"
},
"durationMs": 55313
}
]
}
+54 -1
View File
@@ -7,6 +7,44 @@ schedule), UDP data plane (ECHO with observation block, TIMESYNC, HMAC gate, ant
anti-amplification — wire format covered by tests). Not yet: TCP/TLS echo, STUN, canary DNS, anti-amplification — wire format covered by tests). Not yet: TCP/TLS echo, STUN, canary DNS,
actions, observations API, admin UI beyond token minting. actions, observations API, admin UI beyond token minting.
## Deployment requirements
**No reverse proxy, no 80/443 — by design.** Traefik/nginx on the same host are fine; this
server never touches their ports, and putting it *behind* them would break two protocol
properties:
- Clients trust the control plane **only** via the SPKI pin from enrollment (self-signed is
first-class). A proxy terminates TLS with its own rotating ACME cert → pins break. The pin
model exists so no real certificate is ever needed.
- On the data plane, **the observed source address/port/TTL/DSCP *is* the measurement**. Any
proxy or NAT layer (including Docker's) substitutes its own — hence host networking.
What a target host actually needs:
| Port | Proto | Purpose | Notes |
|---|---|---|---|
| 8443 | tcp | control plane (pinned HTTPS) | any port — it travels in the enrollment QR + profile |
| 8442 | udp | UDP probe data plane | any port, profile-driven |
| 8441 | tcp | TCP/TLS echo | any port (not yet implemented) |
| 3478 | udp | STUN | keep standard: vanilla RFC 5389 for tool interop; rarely contended |
| 8444 | tcp | admin | loopback-only by design — reach via SSH tunnel |
All configurable via `ECHOLOT_*_LISTEN`. Plus:
1. **A public IP on the host** (v4, ideally also v6 — v6 topology issues are half of what
clients want to measure). Behind NAT, plain port-forwards work.
2. **Second IP (optional):** full RFC 5780 NAT-behavior discovery (`stun-5780`) needs an
alternate reply address; without it the profile advertises `stun-basic` and clients degrade
gracefully.
3. **Delegated DNS subzone (optional, later):** the `canary-dns` capability needs port 53 on
some IP + an NS delegation (mind systemd-resolved on 127.0.0.53). Absent → capability simply
not advertised.
4. **Outbound freedom** for connect-back / delayed-echo actions — no extra inbound ports;
generated traffic goes only to the session's observed source.
Deliberately out of scope here: an echo listener on 443 (to detect port-based egress filtering)
— that genuinely needs 443 and belongs on a dedicated IP, not on a host running a reverse proxy.
## Run in Docker (config via env) ## Run in Docker (config via env)
```sh ```sh
@@ -27,7 +65,22 @@ sudo /usr/local/bin/echolot-server --uninstall-systemd
``` ```
Config precedence: flags > `ECHOLOT_*` env > defaults. Every flag has an env twin Config precedence: flags > `ECHOLOT_*` env > defaults. Every flag has an env twin
(`--udp-listen``ECHOLOT_UDP_LISTEN`). (`--udp-listen``ECHOLOT_UDP_LISTEN`). Host config lives in `/etc/echolot-server.env`
(seeded by `--install-systemd`, never overwritten).
**Multi-IP hosts:** listen specs are comma-separated, and you should bind explicit addresses —
a wildcard bind would also claim management-only IPs:
```sh
ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443
ECHOLOT_UDP_LISTEN=203.0.113.10:8442,203.0.113.11:8442,[2001:db8::10]:8442,[2001:db8::11]:8442
```
Passing `--self-update-api` to `--install-systemd` additionally installs a daily randomized
self-update timer (`echolot-server-update.timer`) that restarts the service after a successful
update. Updates are checksum-verified against the release's `SHA256SUMS` (integrity, not
authenticity — signature verification remains TODO before treating the update source as
untrusted).
### Self-update (opt-in, native only) ### Self-update (opt-in, native only)
+49 -19
View File
@@ -63,8 +63,10 @@ func run() error {
fmt.Println(Version) fmt.Println(Version)
return nil return nil
case actions.InstallSystemd: case actions.InstallSystemd:
// The unit runs this same binary in serve mode with env-based config. // The unit runs this same binary in serve mode; host config comes
return system.InstallSystemd(nil) // from /etc/echolot-server.env. A self-update timer is installed
// only when an update API is configured.
return system.InstallSystemd(cfg.SelfUpdateAPI)
case actions.UninstallSystemd: case actions.UninstallSystemd:
return system.UninstallSystemd() return system.UninstallSystemd()
case actions.SelfUpdate: case actions.SelfUpdate:
@@ -95,20 +97,31 @@ func serve(cfg *config.Config) error {
sessions := session.NewManager(15 * time.Minute) sessions := session.NewManager(15 * time.Minute)
ctl := &control.Server{ ctl := &control.Server{
Store: st, Sessions: sessions, Name: cfg.Name, Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(cfg.UDPListen), TCPPort: mustPort(cfg.TCPListen), PinB64: pin, UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), PinB64: pin,
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
errCh := make(chan error, 4) ctlAddrs := config.Addrs(cfg.ControlListen)
udpAddrs := config.Addrs(cfg.UDPListen)
errCh := make(chan error, len(ctlAddrs)+len(udpAddrs)+2)
// Control plane (HTTPS, pin-based trust) // Control plane (HTTPS, pin-based trust) — one shared server, one
// listener per configured address; Shutdown closes them all.
ctlSrv := &http.Server{ ctlSrv := &http.Server{
Addr: cfg.ControlListen, Handler: ctl.Handler(), Handler: ctl.Handler(),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
go func() { errCh <- fmt.Errorf("control: %w", ctlSrv.ListenAndServeTLS("", "")) }() for _, addr := range ctlAddrs {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("control listen %s: %w", addr, err)
}
go func(a string, l net.Listener) {
errCh <- fmt.Errorf("control %s: %w", a, ctlSrv.ServeTLS(l, "", ""))
}(addr, ln)
}
// Admin/health (plain HTTP, localhost by default; spec §7) // Admin/health (plain HTTP, localhost by default; spec §7)
admin := http.NewServeMux() admin := http.NewServeMux()
@@ -129,20 +142,28 @@ func serve(cfg *config.Config) error {
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second} adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
// UDP data plane // UDP data plane — one socket per configured address. Distinct sockets
udpAddr, err := net.ResolveUDPAddr("udp", cfg.UDPListen) // (not wildcard) also guarantee responses leave from the address the
if err != nil { // request arrived on, which stun-5780 will rely on.
return err var udpConns []*net.UDPConn
for _, addr := range udpAddrs {
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("udp addr %s: %w", addr, err)
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return fmt.Errorf("udp listen %s: %w", addr, err)
}
udpConns = append(udpConns, conn)
dp := &dataplane.Server{Sessions: sessions}
go func(a string, c *net.UDPConn) {
errCh <- fmt.Errorf("udp %s: %w", a, dp.Serve(c))
}(addr, conn)
} }
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return fmt.Errorf("udp listen: %w", err)
}
dp := &dataplane.Server{Sessions: sessions}
go func() { errCh <- fmt.Errorf("udp: %w", dp.Serve(udpConn)) }()
slog.Info("listening", slog.Info("listening",
"control", cfg.ControlListen, "admin", cfg.AdminListen, "udp", cfg.UDPListen) "control", ctlAddrs, "admin", cfg.AdminListen, "udp", udpAddrs)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -151,13 +172,22 @@ func serve(cfg *config.Config) error {
defer cancel() defer cancel()
_ = ctlSrv.Shutdown(shutCtx) _ = ctlSrv.Shutdown(shutCtx)
_ = adminSrv.Shutdown(shutCtx) _ = adminSrv.Shutdown(shutCtx)
_ = udpConn.Close() for _, c := range udpConns {
_ = c.Close()
}
return nil return nil
case err := <-errCh: case err := <-errCh:
return err return err
} }
} }
func firstAddr(spec string) string {
if a := config.Addrs(spec); len(a) > 0 {
return a[0]
}
return ""
}
func mustPort(listen string) int { func mustPort(listen string) int {
_, p, err := net.SplitHostPort(listen) _, p, err := net.SplitHostPort(listen)
if err != nil { if err != nil {
+17 -3
View File
@@ -60,11 +60,11 @@ func Load(args []string) (*Config, *Actions, error) {
c := &Config{} c := &Config{}
a := &Actions{} a := &Actions{}
fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address") fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address(es), comma-separated")
fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)") fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)")
fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)") fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)")
fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address") fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address(es), comma-separated")
fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address") fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address(es), comma-separated")
fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)") fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)")
fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)") fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)")
fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name") fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")
@@ -88,6 +88,20 @@ func Load(args []string) (*Config, *Actions, error) {
return c, a, nil return c, a, nil
} }
// Addrs splits a comma-separated listen spec into individual addresses.
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
// (":8443") would also claim addresses reserved for other purposes (e.g. an
// SSH-only management IP).
func Addrs(spec string) []string {
var out []string
for _, a := range strings.Split(spec, ",") {
if a = strings.TrimSpace(a); a != "" {
out = append(out, a)
}
}
return out
}
// Actions are one-shot verbs that exit instead of serving. // Actions are one-shot verbs that exit instead of serving.
type Actions struct { type Actions struct {
InstallSystemd bool InstallSystemd bool
+42 -4
View File
@@ -8,6 +8,8 @@
package selfupdate package selfupdate
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -49,7 +51,11 @@ func Run(api, currentVersion string) error {
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return err return err
} }
if rel.TagName == "" || rel.TagName == currentVersion { // Tags are namespaced (server-v1.2.3) but binaries are stamped with the
// bare version (v1.2.3) — compare the normalized forms or the updater
// would re-download the same version forever.
latest := strings.TrimPrefix(rel.TagName, "server-")
if rel.TagName == "" || latest == currentVersion {
fmt.Printf("already current (%s)\n", currentVersion) fmt.Printf("already current (%s)\n", currentVersion)
return nil return nil
} }
@@ -65,6 +71,35 @@ func Run(api, currentVersion string) error {
return fmt.Errorf("release %s has no asset %q", rel.TagName, want) return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
} }
// The release must carry SHA256SUMS; refuse to update without it. This
// protects download integrity (truncation, proxy mangling). It is NOT a
// defense against a compromised Gitea — both files come from the same
// place; a detached signature would be needed for that (still TODO).
var sums string
for _, a := range rel.Assets {
if a.Name == "SHA256SUMS" {
resp, err := client.Get(a.URL)
if err != nil {
return fmt.Errorf("fetching SHA256SUMS: %w", err)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if err != nil {
return err
}
sums = string(b)
}
}
wantSum := ""
for _, line := range strings.Split(sums, "\n") {
if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want {
wantSum = fields[0]
}
}
if wantSum == "" {
return fmt.Errorf("release %s has no SHA256SUMS entry for %q — refusing to update", rel.TagName, want)
}
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
return err return err
@@ -81,15 +116,18 @@ func Run(api, currentVersion string) error {
os.Remove(tmp) os.Remove(tmp)
return err return err
} }
_, err = io.Copy(f, dl.Body) h := sha256.New()
_, err = io.Copy(io.MultiWriter(f, h), dl.Body)
dl.Body.Close() dl.Body.Close()
f.Close() f.Close()
if err != nil { if err != nil {
os.Remove(tmp) os.Remove(tmp)
return err return err
} }
// TODO(security): verify a detached signature/checksum asset before the if got := hex.EncodeToString(h.Sum(nil)); got != wantSum {
// rename — a Gitea compromise currently equals code execution here. os.Remove(tmp)
return fmt.Errorf("checksum mismatch for %s: got %s want %s", want, got, wantSum)
}
if err := os.Rename(tmp, self); err != nil { if err := os.Rename(tmp, self); err != nil {
os.Remove(tmp) os.Remove(tmp)
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err) return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
+75 -17
View File
@@ -2,8 +2,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// Package system implements native-host lifecycle: systemd unit install / // Package system implements native-host lifecycle: systemd unit install /
// uninstall. Linux-only by nature; on other OSes the commands fail with a // uninstall, plus an optional self-update timer. Linux-only by nature; on
// clear message rather than pretending. // other OSes the commands fail with a clear message rather than pretending.
package system package system
import ( import (
@@ -14,7 +14,12 @@ import (
"runtime" "runtime"
) )
const unitPath = "/etc/systemd/system/echolot-server.service" const (
unitPath = "/etc/systemd/system/echolot-server.service"
updateUnitPath = "/etc/systemd/system/echolot-server-update.service"
updateTimerPath = "/etc/systemd/system/echolot-server-update.timer"
envFilePath = "/etc/echolot-server.env"
)
const unitTemplate = `[Unit] const unitTemplate = `[Unit]
Description=Echolot probe server Description=Echolot probe server
@@ -29,6 +34,8 @@ Restart=on-failure
RestartSec=5 RestartSec=5
StateDirectory=echolot-server StateDirectory=echolot-server
Environment=ECHOLOT_STATE_DIR=/var/lib/echolot-server Environment=ECHOLOT_STATE_DIR=/var/lib/echolot-server
# Host-specific config (listen addresses etc.) lives here, not in the unit:
EnvironmentFile=-%s
# Hardening — the server needs sockets and its state dir, nothing else. # Hardening — the server needs sockets and its state dir, nothing else.
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
@@ -40,9 +47,43 @@ PrivateTmp=true
WantedBy=multi-user.target WantedBy=multi-user.target
` `
// InstallSystemd writes the unit for THIS binary (absolute path), reloads const updateUnitTemplate = `[Unit]
// systemd, and enables the service. Idempotent. Description=Echolot server self-update
func InstallSystemd(extraArgs []string) error { After=network-online.target
[Service]
Type=oneshot
ExecStart=%s --self-update --self-update-api=%s
# The updater only replaces the binary; the restart activates it.
ExecStartPost=/usr/bin/systemctl try-restart echolot-server.service
`
const updateTimerTemplate = `[Unit]
Description=Daily Echolot server self-update check
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
`
const envFileTemplate = `# Echolot server host configuration (systemd EnvironmentFile).
# Bind explicit addresses on multi-IP hosts — a wildcard would also claim
# management-only addresses. Comma-separated lists are supported.
#ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443
#ECHOLOT_UDP_LISTEN=203.0.113.10:8442,[2001:db8::10]:8442
#ECHOLOT_TCP_LISTEN=203.0.113.10:8441,[2001:db8::10]:8441
#ECHOLOT_ADMIN_LISTEN=127.0.0.1:8444
#ECHOLOT_NAME=my-server
`
// InstallSystemd writes the unit(s) for THIS binary (absolute path), reloads
// systemd, and enables the service. When selfUpdateAPI is non-empty, a daily
// self-update timer is installed alongside. Idempotent.
func InstallSystemd(selfUpdateAPI string) error {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS) return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS)
} }
@@ -54,22 +95,36 @@ func InstallSystemd(extraArgs []string) error {
if err != nil { if err != nil {
return err return err
} }
execStart := self if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, self, envFilePath)), 0o644); err != nil {
for _, a := range extraArgs {
execStart += " " + a
}
if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, execStart)), 0o644); err != nil {
return fmt.Errorf("writing %s (need root?): %w", unitPath, err) return fmt.Errorf("writing %s (need root?): %w", unitPath, err)
} }
for _, cmd := range [][]string{ // Seed the env file once; never overwrite an existing one.
if _, err := os.Stat(envFilePath); os.IsNotExist(err) {
_ = os.WriteFile(envFilePath, []byte(envFileTemplate), 0o644)
}
cmds := [][]string{
{"systemctl", "daemon-reload"}, {"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "echolot-server.service"}, {"systemctl", "enable", "--now", "echolot-server.service"},
} { }
if selfUpdateAPI != "" {
if err := os.WriteFile(updateUnitPath,
[]byte(fmt.Sprintf(updateUnitTemplate, self, selfUpdateAPI)), 0o644); err != nil {
return err
}
if err := os.WriteFile(updateTimerPath, []byte(updateTimerTemplate), 0o644); err != nil {
return err
}
cmds = append(cmds, []string{"systemctl", "enable", "--now", "echolot-server-update.timer"})
}
for _, cmd := range cmds {
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s: %w", cmd, out, err) return fmt.Errorf("%v: %s: %w", cmd, out, err)
} }
} }
fmt.Printf("installed + started echolot-server.service (ExecStart=%s)\n", execStart) fmt.Printf("installed echolot-server.service (ExecStart=%s, config: %s)\n", self, envFilePath)
if selfUpdateAPI != "" {
fmt.Println("installed echolot-server-update.timer (daily, randomized)")
}
return nil return nil
} }
@@ -78,11 +133,14 @@ func UninstallSystemd() error {
return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS) return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS)
} }
// Stop/disable first; ignore "not loaded" errors so uninstall is idempotent. // Stop/disable first; ignore "not loaded" errors so uninstall is idempotent.
_ = exec.Command("systemctl", "disable", "--now", "echolot-server-update.timer").Run()
_ = exec.Command("systemctl", "disable", "--now", "echolot-server.service").Run() _ = exec.Command("systemctl", "disable", "--now", "echolot-server.service").Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) { for _, p := range []string{unitPath, updateUnitPath, updateTimerPath} {
return err if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
} }
_ = exec.Command("systemctl", "daemon-reload").Run() _ = exec.Command("systemctl", "daemon-reload").Run()
fmt.Println("removed echolot-server.service (state dir left in place)") fmt.Println("removed echolot-server units (state dir and env file left in place)")
return nil return nil
} }