Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Opus 5 35baf70cdb server: canary DNS — authoritative zone with frozen §6.1 reference records
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 26s
server-release / release (push) Successful in 27s
Stdlib DNS responder (no external deps): parses single-question queries
with EDNS OPT (bufsize, DO, ECS), serves the spec's frozen reference
records (ttl-{5,60,3600,86400} A/AAAA/TXT, many-rr 8×A in order, big-txt
~1800B), and per-query <nonce>.<session>.<zone> answers in 192.0.2.0/24.
UDP truncation sets TC past 512 (or the EDNS bufsize); TCP never
truncates — the EDNS-bufsize / TCP-fallback test. Every query is logged
(qname, resolver, transport, EDNS, ECS, case) and surfaced per session
prefix in GET /v1/sessions/{id}/observations as dns_canary. Profile gains
canary_zone + the canary-dns capability when configured.

Wire format validated against an independent client (correct rcodes,
answer counts, TC behavior, full EDNS response); unit tests cover
references, truncation-vs-EDNS, logging, NXDOMAIN.

Versioning: patch-first convention recorded in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:08:30 +02:00
mrambossekandClaude Opus 5 4f5499198b build-status: server v0.3.0 live on fmr — STUN/TCP-echo/observations/actions verified
Deployed via self-update (first real run). External checks: stun-5780
advertised, STUN binding OK v4+v6 with OTHER-ADDRESS, TCP echo mss=1440
over IPv6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:56:22 +02:00
mrambossekandClaude Opus 5 7b676e666e server: STUN, TCP echo, observations API, delayed-echo + connect-back actions
server-test / test (push) Successful in 27s
server-release / image (push) Successful in 14s
server-release / release (push) Successful in 27s
- stun: RFC 5389 binding responder + RFC 5780 attributes (OTHER-ADDRESS,
  RESPONSE-ORIGIN, CHANGE-REQUEST) on a primary/alt-port socket grid per
  address; advertises stun-5780 with >=2 same-family addrs, else
  stun-basic. Unmodified framing for tooling interop. Tested.
- tcpecho: JSON greeting with observed src + TCP_INFO MSS/options
  (Linux getsockopt; zeroed elsewhere via build tags), then byte echo.
- session: per-packet UDP observations + connect-back results, ByID lookup.
- control: GET /v1/sessions/{id}/observations, POST .../actions
  (delayed_echo → DELAYED_ECHO at the observed data-plane source;
  connect_back → dial the control-plane source, record connected/refused/
  timeout+rtt). Capabilities computed from what is actually wired.
- config/main: comma-separated STUN listeners; all planes bind explicit
  addresses; graceful shutdown of the new listeners.

Full flow smoke-tested; go test green (stun binding/change-port,
dataplane wire format).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:53:36 +02:00
mrambossekandClaude Opus 5 507a8bfc1f build-status: production server v0.2.0 live on the fmr VM
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:42:56 +02:00
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
25 changed files with 2471 additions and 86 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)"
] ]
} }
+20 -9
View File
@@ -30,6 +30,13 @@ is the mutable one — update it as work lands.
Keep prober result IDs aligned with the measurement-schema test-type registry. Keep prober result IDs aligned with the measurement-schema test-type registry.
## Versioning
Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps
for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the
Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release +
registry image; the server on fmr can `--self-update` from those releases.
## Layout ## Layout
Monorepo. The prober is one deliverable; the Go server and the production app land as siblings. Monorepo. The prober is one deliverable; the Go server and the production app land as siblings.
@@ -63,23 +70,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.
+76 -5
View File
@@ -159,15 +159,86 @@ 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.
## Production probe server — LIVE on dedicated VM "fmr" (2026-07-31)
`echolot-server v0.2.0` runs natively (systemd, no docker) on a dedicated VM: 2×IPv4 + 2×IPv6
service addresses (fmr-1/fmr-2.echo-lot.app, dual-stack DNS), a third IPv6 (`::2`) reserved for
SSH only — verified untouched by the daemon (explicit multi-address binds, no wildcard).
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).
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)
Shipped and deployed to fmr via the server's own `--self-update` (first real exercise:
checksum-verified download v0.2.0→v0.3.0, atomic replace, restart — worked). Added over v0.2.0:
- **STUN** (RFC 5389 + 5780): 4 service addrs × primary/alt-port grid. Externally verified on
v4 AND v6 — binding success with XOR-MAPPED, RESPONSE-ORIGIN, OTHER-ADDRESS present, so the
profile now advertises **`stun-5780`** (the second IP earns its keep).
- **TCP echo** (:8441): JSON greeting with observed src + real Linux TCP_INFO — verified
externally `mss:1440` (v6, 150060), options `[sack,wscale]`, then byte-echo.
- **Observations API** `GET /v1/sessions/{id}/observations` (per-packet UDP view, connect-back
results, TCP records correlated by source IP).
- **Actions** `POST /v1/sessions/{id}/actions`: `delayed_echo` (DELAYED_ECHO at the observed
data-plane source — NAT-lifetime primitive) and `connect_back` (dials the control-plane
source, records connected/refused/timeout+rtt).
- Capabilities computed from what's actually wired: `udp-probe, delayed-echo, connect-back,
tcp-echo, stun-5780`.
Still not implemented: TLS-echo/JA4, HTTP echo, tls-reference, canary DNS (§6.1 reference
records), and the train/big-send/frag/throughput actions. Admin UI still token-mint + health only.
+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
}
]
}
+62 -1
View File
@@ -7,6 +7,52 @@ 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 (for `canary-dns`):** set `ECHOLOT_DNS_LISTEN` (udp+tcp/53 on the
service IPs) and `ECHOLOT_CANARY_ZONE` (e.g. `c.echo-lot.app`), then delegate the zone to
this host in your DNS provider:
```
c.echo-lot.app. NS fmr-1.echo-lot.app.
c.echo-lot.app. NS fmr-2.echo-lot.app.
```
The server is authoritative for that zone only, serving the spec §6.1 reference records
(frozen in `internal/canarydns/dns_reference.go`) plus per-query `<nonce>.<session>.<zone>`
lookups it logs. Binding :53 on the public IPs is fine even with systemd-resolved (it only
claims 127.0.0.53). Absent config → 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 +73,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)
+149 -16
View File
@@ -25,6 +25,7 @@ import (
"math/big" "math/big"
"net" "net"
"net/http" "net/http"
"net/netip"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -32,13 +33,16 @@ import (
"syscall" "syscall"
"time" "time"
"echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/config" "echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control" "echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane" "echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/selfupdate" "echo-lot.app/server/internal/selfupdate"
"echo-lot.app/server/internal/session" "echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store" "echo-lot.app/server/internal/store"
"echo-lot.app/server/internal/stun"
"echo-lot.app/server/internal/system" "echo-lot.app/server/internal/system"
"echo-lot.app/server/internal/tcpecho"
) )
// Version is stamped via -ldflags "-X main.Version=v1.2.3" in CI. // Version is stamped via -ldflags "-X main.Version=v1.2.3" in CI.
@@ -63,8 +67,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:
@@ -93,22 +99,44 @@ func serve(cfg *config.Config) error {
slog.Info("control-plane certificate", "pin-sha256", pin) slog.Info("control-plane certificate", "pin-sha256", pin)
sessions := session.NewManager(15 * time.Minute) sessions := session.NewManager(15 * time.Minute)
dp := &dataplane.Server{Sessions: sessions}
tcpSrv := &tcpecho.Server{}
caps := []string{"udp-probe", "delayed-echo", "connect-back"}
if len(config.Addrs(cfg.TCPListen)) > 0 {
caps = append(caps, "tcp-echo")
}
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)),
StunPort: mustPort(firstAddr(cfg.StunListen)), PinB64: pin,
DelayedEcho: dp.SendDelayedEcho,
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
} }
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 +157,89 @@ 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
// request arrived on, which stun-5780 will rely on.
var udpConns []*net.UDPConn
for _, addr := range udpAddrs {
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil { if err != nil {
return err return fmt.Errorf("udp addr %s: %w", addr, err)
} }
udpConn, err := net.ListenUDP("udp", udpAddr) conn, err := net.ListenUDP("udp", udpAddr)
if err != nil { if err != nil {
return fmt.Errorf("udp listen: %w", err) return fmt.Errorf("udp listen %s: %w", addr, err)
}
udpConns = append(udpConns, conn)
go func(a string, c *net.UDPConn) {
errCh <- fmt.Errorf("udp %s: %w", a, dp.Serve(c))
}(addr, conn)
}
// TCP echo (spec §4)
var tcpLns []net.Listener
for _, addr := range config.Addrs(cfg.TCPListen) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("tcp listen %s: %w", addr, err)
}
tcpLns = append(tcpLns, ln)
go func(a string, l net.Listener) {
errCh <- fmt.Errorf("tcp %s: %w", a, tcpSrv.Serve(l))
}(addr, ln)
}
// STUN (spec §4) — advertises stun-5780 only with ≥2 same-family addrs.
var stunSrv *stun.Server
if stunAddrs := config.Addrs(cfg.StunListen); len(stunAddrs) > 0 {
stunSrv, err = stun.Listen(stunAddrs)
if err != nil {
return fmt.Errorf("stun listen: %w", err)
}
go func() { errCh <- fmt.Errorf("stun: %w", stunSrv.Serve()) }()
if stunSrv.Has5780() {
ctl.Capabilities = append(caps, "stun-5780")
} else {
ctl.Capabilities = append(caps, "stun-basic")
}
} else {
ctl.Capabilities = caps
}
// Canary DNS (spec §6.1) — authoritative for CanaryZone, udp+tcp per addr.
var dnsUDP []*net.UDPConn
var dnsTCP []net.Listener
if dnsAddrs := config.Addrs(cfg.DNSListen); len(dnsAddrs) > 0 && cfg.CanaryZone != "" {
v4, v6 := firstByFamily(dnsAddrs)
cd := canarydns.New(cfg.CanaryZone, cfg.Name, v4, v6)
for _, addr := range dnsAddrs {
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("dns udp addr %s: %w", addr, err)
}
uc, err := net.ListenUDP("udp", ua)
if err != nil {
return fmt.Errorf("dns udp listen %s: %w", addr, err)
}
dnsUDP = append(dnsUDP, uc)
go func(a string, c *net.UDPConn) { errCh <- fmt.Errorf("dns-udp %s: %w", a, cd.ServeUDP(c)) }(addr, uc)
tl, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("dns tcp listen %s: %w", addr, err)
}
dnsTCP = append(dnsTCP, tl)
go func(a string, l net.Listener) { errCh <- fmt.Errorf("dns-tcp %s: %w", a, cd.ServeTCP(l)) }(addr, tl)
}
ctl.CanaryZone = cfg.CanaryZone
ctl.CanaryQueries = func(prefix string) any { return cd.RecentForPrefix(prefix) }
ctl.Capabilities = append(ctl.Capabilities, "canary-dns")
} }
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,
"tcp", config.Addrs(cfg.TCPListen), "stun", config.Addrs(cfg.StunListen),
"dns", config.Addrs(cfg.DNSListen), "capabilities", ctl.Capabilities)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -151,13 +248,49 @@ 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()
}
for _, l := range tcpLns {
_ = l.Close()
}
if stunSrv != nil {
stunSrv.Close()
}
for _, c := range dnsUDP {
_ = c.Close()
}
for _, l := range dnsTCP {
_ = l.Close()
}
return nil return nil
case err := <-errCh: case err := <-errCh:
return err return err
} }
} }
// firstByFamily returns the first v4 and first v6 address from a list of
// "ip:port" specs — used for the canary zone's apex/NS answers.
func firstByFamily(addrs []string) (v4, v6 netip.Addr) {
for _, a := range addrs {
if ap, err := netip.ParseAddrPort(a); err == nil {
if ap.Addr().Unmap().Is4() && !v4.IsValid() {
v4 = ap.Addr().Unmap()
} else if ap.Addr().Is6() && !ap.Addr().Is4In6() && !v6.IsValid() {
v6 = ap.Addr()
}
}
}
return
}
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 {
+287
View File
@@ -0,0 +1,287 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package canarydns
import (
"encoding/binary"
"hash/fnv"
"net"
"net/netip"
"strings"
"sync"
"time"
)
// DNS constants (RFC 1035 + RFC 6891 EDNS).
const (
typeA = 1
typeNS = 2
typeTXT = 16
typeAAAA = 28
typeOPT = 41
classIN = 1
rcodeNoError = 0
rcodeNXDomain = 3
flagQR = 0x8000
flagAA = 0x0400
flagTC = 0x0200
flagRD = 0x0100
flagRA = 0x0080
udpMaxNoEDNS = 512
ednsDO = 0x8000 // DO bit lives in the OPT TTL field's high half
optECS = 8 // EDNS Client Subnet option code
)
// Query is one logged canary lookup (spec §6 dns_canary shape).
type Query struct {
QName string `json:"qname"`
At time.Time `json:"at"`
ResolverIP string `json:"resolver_ip"`
Transport string `json:"transport"` // "udp" | "tcp"
EDNS edns `json:"edns"`
ECS string `json:"ecs,omitempty"`
CasePreserved bool `json:"case_preserved"`
// qname_minimized is not reliably detectable authoritative-side without
// cross-query correlation; left false (TODO) rather than guessed.
QNameMinimized bool `json:"qname_minimized"`
}
type edns struct {
Present bool `json:"present"`
Bufsize int `json:"bufsize"`
Flags []string `json:"flags"`
}
// Server is the authoritative responder for one canary zone.
type Server struct {
zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app."
nsName string // this server's own name for NS/authority answers
primaryV4 netip.Addr
primaryV6 netip.Addr
mu sync.Mutex
log []Query // ring, newest last
retainTo time.Time
}
const logCap = 8192
// New creates a server for zone (with or without trailing dot). nsName is the
// server's own hostname (for the zone's NS record); primary v4/v6 are this
// host's addresses used to answer the zone apex / NS glue.
func New(zone, nsName string, v4, v6 netip.Addr) *Server {
z := strings.ToLower(strings.TrimSuffix(zone, ".")) + "."
return &Server{zone: z, nsName: strings.TrimSuffix(nsName, ".") + ".", primaryV4: v4, primaryV6: v6}
}
// RecentForPrefix returns logged queries whose qname contains ".<prefix>."
// (the session prefix the app embeds: <nonce>.<session-prefix>.<zone>).
func (s *Server) RecentForPrefix(prefix string) []Query {
s.mu.Lock()
defer s.mu.Unlock()
needle := "." + strings.ToLower(prefix) + "."
var out []Query
for _, q := range s.log {
if strings.Contains(strings.ToLower(q.QName), needle) {
out = append(out, q)
}
}
return out
}
func (s *Server) record(q Query) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.log) >= logCap {
s.log = s.log[1:]
}
s.log = append(s.log, q)
}
// ServeUDP / ServeTCP run read loops; call one per bound address.
func (s *Server) ServeUDP(conn *net.UDPConn) error {
buf := make([]byte, 1500)
for {
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
return err
}
resp := s.handle(buf[:n], raddr.Addr(), "udp")
if resp != nil {
_, _ = conn.WriteToUDPAddrPort(resp, raddr)
}
}
}
func (s *Server) ServeTCP(ln net.Listener) error {
for {
c, err := ln.Accept()
if err != nil {
return err
}
go s.handleTCP(c)
}
}
func (s *Server) handleTCP(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
var lenBuf [2]byte
if _, err := readFull(c, lenBuf[:]); err != nil {
return
}
msg := make([]byte, binary.BigEndian.Uint16(lenBuf[:]))
if _, err := readFull(c, msg); err != nil {
return
}
ra, _ := netip.ParseAddrPort(c.RemoteAddr().String())
resp := s.handle(msg, ra.Addr(), "tcp")
if resp == nil {
return
}
// TCP has no 512 limit; never truncate.
out := make([]byte, 2+len(resp))
binary.BigEndian.PutUint16(out[0:2], uint16(len(resp)))
copy(out[2:], resp)
_, _ = c.Write(out)
}
func readFull(c net.Conn, b []byte) (int, error) {
got := 0
for got < len(b) {
n, err := c.Read(b[got:])
got += n
if err != nil {
return got, err
}
}
return got, nil
}
// handle parses one query, logs it, and returns the wire response (nil to drop).
func (s *Server) handle(pkt []byte, resolver netip.Addr, transport string) []byte {
if len(pkt) < 12 {
return nil
}
id := binary.BigEndian.Uint16(pkt[0:2])
qdcount := binary.BigEndian.Uint16(pkt[4:6])
arcount := binary.BigEndian.Uint16(pkt[10:12])
if qdcount != 1 {
return s.errorResponse(id, rcodeNoError, nil) // we only answer single-question queries
}
qnameRaw, qtype, _, qEnd, ok := parseQuestion(pkt, 12)
if !ok {
return nil
}
// EDNS OPT is an additional-section RR; scan for it after the question.
opt := parseOPT(pkt, qEnd, arcount)
// Log every query — this is the whole point of the canary zone.
q := Query{
QName: strings.TrimSuffix(qnameRaw, "."), At: time.Now().UTC(),
ResolverIP: resolver.Unmap().String(), Transport: transport,
EDNS: opt.edns,
ECS: opt.ecs,
CasePreserved: qnameRaw == strings.ToLower(qnameRaw), // mixed case ⇒ 0x20 randomization
}
s.record(q)
name := strings.ToLower(qnameRaw)
if !strings.HasSuffix(name, s.zone) {
return s.errorResponse(id, rcodeNXDomain, &opt)
}
sub := strings.TrimSuffix(name, s.zone) // e.g. "ttl-5." or "" for apex
return s.answer(id, pkt, qEnd, sub, qtype, &opt, transport)
}
// answer builds the response for a name known to be in-zone.
func (s *Server) answer(id uint16, pkt []byte, qEnd int, sub string, qtype uint16, opt *optInfo, transport string) []byte {
labels := splitLabels(sub) // e.g. ["ttl-5"], [], ["<nonce>","miss"], ["<nonce>","<sessprefix>"]
var rrs []rr
switch {
case len(labels) == 0: // zone apex
if qtype == typeNS {
rrs = append(rrs, rr{ttl: 3600, typ: typeNS, ns: s.nsName})
} else if qtype == typeA && s.primaryV4.IsValid() {
rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: s.primaryV4})
} else if qtype == typeAAAA && s.primaryV6.IsValid() {
rrs = append(rrs, rr{ttl: 3600, typ: typeAAAA, addr: s.primaryV6})
}
case len(labels) == 1:
if ref := findReference(labels[0]); ref != nil {
rrs = referenceAnswers(ref, qtype)
}
default:
// Per-query names: <nonce>.miss.<zone> and <nonce>.<session-prefix>.<zone>.
// Deterministic A derived from the leftmost label (the nonce), TTL 3600,
// documentation range — ground truth that can never be pre-cached.
if qtype == typeA {
rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: nonceAddr(labels[0])})
}
}
if len(rrs) == 0 {
// In-zone but no such record/type → NOERROR/NODATA (or NXDOMAIN at apex miss).
return s.buildResponse(id, pkt, qEnd, nil, opt, transport, rcodeNoError)
}
return s.buildResponse(id, pkt, qEnd, rrs, opt, transport, rcodeNoError)
}
// nonceAddr maps a nonce label into 192.0.2.0/24 deterministically.
func nonceAddr(nonce string) netip.Addr {
h := fnv.New32a()
_, _ = h.Write([]byte(nonce))
return netip.AddrFrom4([4]byte{192, 0, 2, byte(h.Sum32()%254 + 1)})
}
func referenceAnswers(ref *refRecord, qtype uint16) []rr {
var rrs []rr
switch qtype {
case typeA:
for _, a := range ref.a {
if a.Is4() {
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeA, addr: a})
}
}
case typeAAAA:
for _, a := range ref.a {
if a.Is6() && !a.Is4In6() {
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeAAAA, addr: a})
}
}
case typeTXT:
if len(ref.txt) > 0 {
rrs = append(rrs, rr{ttl: ref.ttl, typ: typeTXT, txt: ref.txt})
}
}
return rrs
}
func splitLabels(sub string) []string {
sub = strings.TrimSuffix(sub, ".")
if sub == "" {
return nil
}
return strings.Split(sub, ".")
}
func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte {
hdr := make([]byte, 12)
binary.BigEndian.PutUint16(hdr[0:2], id)
binary.BigEndian.PutUint16(hdr[2:4], uint16(flagQR|flagAA|rcode))
if opt != nil && opt.edns.Present {
binary.BigEndian.PutUint16(hdr[10:12], 1)
return append(hdr, buildOPT(opt)...)
}
return hdr
}
+161
View File
@@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package canarydns
import (
"encoding/binary"
"net"
"net/netip"
"testing"
)
// buildQuery makes a single-question DNS query, optionally with an EDNS OPT.
func buildQuery(name string, qtype uint16, ednsBufsize int) []byte {
msg := make([]byte, 12)
binary.BigEndian.PutUint16(msg[0:2], 0x1234)
binary.BigEndian.PutUint16(msg[2:4], flagRD)
binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT
msg = append(msg, encodeName(name)...)
msg = binary.BigEndian.AppendUint16(msg, qtype)
msg = binary.BigEndian.AppendUint16(msg, classIN)
if ednsBufsize > 0 {
binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT
msg = append(msg, 0) // root name
msg = binary.BigEndian.AppendUint16(msg, typeOPT)
msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize))
msg = binary.BigEndian.AppendUint32(msg, 0)
msg = binary.BigEndian.AppendUint16(msg, 0)
}
return msg
}
// parseAnswers pulls (type, ttl, rdata) tuples from a response.
type ans struct {
typ uint16
ttl uint32
data []byte
}
func parseResponse(t *testing.T, resp []byte) (flags uint16, answers []ans) {
t.Helper()
flags = binary.BigEndian.Uint16(resp[2:4])
qd := binary.BigEndian.Uint16(resp[4:6])
an := binary.BigEndian.Uint16(resp[6:8])
off := 12
for i := uint16(0); i < qd; i++ {
_, next, ok := readName(resp, off)
if !ok {
t.Fatal("bad question name")
}
off = next + 4
}
for i := uint16(0); i < an; i++ {
_, next, ok := readName(resp, off)
if !ok {
t.Fatal("bad answer name")
}
typ := binary.BigEndian.Uint16(resp[next : next+2])
ttl := binary.BigEndian.Uint32(resp[next+4 : next+8])
rdlen := int(binary.BigEndian.Uint16(resp[next+8 : next+10]))
answers = append(answers, ans{typ, ttl, resp[next+10 : next+10+rdlen]})
off = next + 10 + rdlen
}
return
}
func newTestServer() *Server {
return New("c.echo-lot.app", "fmr", netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1"))
}
func TestReferenceRecords(t *testing.T) {
s := newTestServer()
resolver := netip.MustParseAddr("198.51.100.7")
// ttl-5 A → 192.0.2.5, TTL 5
resp := s.handle(buildQuery("ttl-5.c.echo-lot.app", typeA, 0), resolver, "udp")
_, answers := parseResponse(t, resp)
if len(answers) != 1 || answers[0].ttl != 5 || !netip.AddrFrom4([4]byte(answers[0].data)).IsValid() {
t.Fatalf("ttl-5 A: %+v", answers)
}
if got := net.IP(answers[0].data).String(); got != "192.0.2.5" {
t.Fatalf("ttl-5 A = %s, want 192.0.2.5", got)
}
// many-rr → exactly 8 A records, in order .101..108
resp = s.handle(buildQuery("many-rr.c.echo-lot.app", typeA, 0), resolver, "udp")
_, answers = parseResponse(t, resp)
if len(answers) != 8 {
t.Fatalf("many-rr: got %d A records, want 8", len(answers))
}
for i, a := range answers {
if a.data[3] != byte(101+i) {
t.Fatalf("many-rr order: record %d = .%d, want .%d", i, a.data[3], 101+i)
}
}
}
func TestBigTxtTruncationVsEDNS(t *testing.T) {
s := newTestServer()
resolver := netip.MustParseAddr("198.51.100.7")
// No EDNS → 512 cap → TC set, answers dropped.
resp := s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "udp")
flags, answers := parseResponse(t, resp)
if flags&flagTC == 0 {
t.Fatal("big-txt over plain UDP should set TC")
}
if len(answers) != 0 {
t.Fatalf("truncated response should carry no answers, got %d", len(answers))
}
// EDNS bufsize 4096 → full answer, no TC.
resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 4096), resolver, "udp")
flags, answers = parseResponse(t, resp)
if flags&flagTC != 0 {
t.Fatal("big-txt with EDNS 4096 should not truncate")
}
if len(answers) != 1 {
t.Fatalf("want 1 TXT answer, got %d", len(answers))
}
// TCP → never truncates.
resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "tcp")
flags, _ = parseResponse(t, resp)
if flags&flagTC != 0 {
t.Fatal("TCP must not truncate")
}
}
func TestQueryLogAndPerPrefix(t *testing.T) {
s := newTestServer()
s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 1232), netip.MustParseAddr("198.51.100.7"), "udp")
s.handle(buildQuery("def456.other.c.echo-lot.app", typeA, 0), netip.MustParseAddr("203.0.113.9"), "udp")
all := s.RecentForPrefix("sessprefix1")
if len(all) != 1 {
t.Fatalf("per-prefix filter: got %d, want 1", len(all))
}
q := all[0]
if q.ResolverIP != "198.51.100.7" || q.Transport != "udp" {
t.Fatalf("logged resolver/transport wrong: %+v", q)
}
if !q.EDNS.Present || q.EDNS.Bufsize != 1232 {
t.Fatalf("EDNS not captured: %+v", q.EDNS)
}
// nonce answer is deterministic + in doc range
resp := s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp")
_, answers := parseResponse(t, resp)
if len(answers) != 1 || answers[0].data[0] != 192 || answers[0].data[1] != 0 || answers[0].data[2] != 2 {
t.Fatalf("nonce answer not in 192.0.2.0/24: %+v", answers)
}
}
func TestOutOfZoneNXDomain(t *testing.T) {
s := newTestServer()
resp := s.handle(buildQuery("example.com", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp")
flags, _ := parseResponse(t, resp)
if flags&0x000F != rcodeNXDomain {
t.Fatalf("out-of-zone should be NXDOMAIN, flags=%#x", flags)
}
}
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package canarydns serves the authoritative canary zone (spec §6.1). The
// reference records below are FROZEN by the protocol spec — names, TTLs, and
// RDATA are ground truth the client compares against, so they must never
// change without a spec revision and a matching update in the app. All
// addresses are documentation-range (RFC 5737 192.0.2.0/24, RFC 3849
// 2001:db8::/32).
package canarydns
import "net/netip"
// refRecord is one frozen reference name (relative to the zone) with its
// per-type answers. A zero value in a field means "no record of that type".
type refRecord struct {
label string
ttl uint32
a []netip.Addr // A / AAAA answers (order preserved)
txt []string // one string per TXT record
}
// referenceRecords are the spec §6.1 fixed records. The RDATA constants are
// frozen HERE (the spec calls this file the source of truth) and mirrored in
// the app. Order within many-rr is part of the test (order/stripping check).
var referenceRecords = []refRecord{
{label: "ttl-5", ttl: 5,
a: []netip.Addr{netip.MustParseAddr("192.0.2.5"), netip.MustParseAddr("2001:db8::5")},
txt: []string{"echolot-ref ttl=5"}},
{label: "ttl-60", ttl: 60,
a: []netip.Addr{netip.MustParseAddr("192.0.2.60"), netip.MustParseAddr("2001:db8::60")},
txt: []string{"echolot-ref ttl=60"}},
{label: "ttl-3600", ttl: 3600,
a: []netip.Addr{netip.MustParseAddr("192.0.2.36"), netip.MustParseAddr("2001:db8::3600")},
txt: []string{"echolot-ref ttl=3600"}},
{label: "ttl-86400", ttl: 86400,
a: []netip.Addr{netip.MustParseAddr("192.0.2.86"), netip.MustParseAddr("2001:db8::8640")},
txt: []string{"echolot-ref ttl=86400"}},
// many-rr: exactly 8 A records in defined order.
{label: "many-rr", ttl: 300, a: []netip.Addr{
netip.MustParseAddr("192.0.2.101"), netip.MustParseAddr("192.0.2.102"),
netip.MustParseAddr("192.0.2.103"), netip.MustParseAddr("192.0.2.104"),
netip.MustParseAddr("192.0.2.105"), netip.MustParseAddr("192.0.2.106"),
netip.MustParseAddr("192.0.2.107"), netip.MustParseAddr("192.0.2.108"),
}},
// big-txt: ~1800 bytes, exercises EDNS bufsize / TCP fallback.
{label: "big-txt", ttl: 300, txt: bigTxt()},
}
// bigTxt builds a deterministic ~1800-byte TXT payload as a sequence of
// 255-byte character-strings (the DNS TXT chunk limit). The content is fixed
// so the client can verify integrity, not just length.
func bigTxt() []string {
const total = 1800
const pattern = "echolot-big-txt-reference-0123456789abcdef-"
buf := make([]byte, 0, total)
for len(buf) < total {
buf = append(buf, pattern...)
}
buf = buf[:total]
var out []string
for len(buf) > 0 {
n := min(255, len(buf))
out = append(out, string(buf[:n]))
buf = buf[n:]
}
return out
}
// findReference returns the reference record for a label, or nil.
func findReference(label string) *refRecord {
for i := range referenceRecords {
if referenceRecords[i].label == label {
return &referenceRecords[i]
}
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package canarydns
import (
"encoding/binary"
"net/netip"
"strings"
)
// rr is a resource record to encode into the answer section.
type rr struct {
ttl uint32
typ uint16
addr netip.Addr // for A/AAAA
txt []string // for TXT
ns string // for NS
}
// optInfo is the parsed EDNS OPT plus the derived observation fields.
type optInfo struct {
edns edns
ecs string
}
// parseQuestion reads a single question starting at off. Returns the raw
// (case-preserved) qname with trailing dot, qtype, qclass, and the offset
// just past the question.
func parseQuestion(pkt []byte, off int) (qname string, qtype, qclass uint16, end int, ok bool) {
name, next, ok := readName(pkt, off)
if !ok || next+4 > len(pkt) {
return "", 0, 0, 0, false
}
qtype = binary.BigEndian.Uint16(pkt[next : next+2])
qclass = binary.BigEndian.Uint16(pkt[next+2 : next+4])
return name, qtype, qclass, next + 4, true
}
// readName decodes a DNS name (with compression pointers) into a
// dot-terminated string, preserving label case.
func readName(pkt []byte, off int) (string, int, bool) {
var sb strings.Builder
end := -1
jumps := 0
for {
if off >= len(pkt) {
return "", 0, false
}
l := int(pkt[off])
switch {
case l == 0:
off++
if end < 0 {
end = off
}
if sb.Len() == 0 {
return ".", end, true
}
return sb.String(), end, true
case l&0xC0 == 0xC0: // compression pointer
if off+1 >= len(pkt) {
return "", 0, false
}
if end < 0 {
end = off + 2
}
off = int(binary.BigEndian.Uint16(pkt[off:off+2]) & 0x3FFF)
jumps++
if jumps > 16 {
return "", 0, false
}
default:
if off+1+l > len(pkt) {
return "", 0, false
}
sb.Write(pkt[off+1 : off+1+l])
sb.WriteByte('.')
off += 1 + l
}
}
}
// parseOPT scans the additional section for an EDNS OPT RR and extracts
// bufsize, the DO flag, and any ECS option.
func parseOPT(pkt []byte, off int, arcount uint16) optInfo {
var info optInfo
for i := uint16(0); i < arcount && off < len(pkt); i++ {
_, next, ok := readName(pkt, off)
if !ok || next+10 > len(pkt) {
return info
}
typ := binary.BigEndian.Uint16(pkt[next : next+2])
class := binary.BigEndian.Uint16(pkt[next+2 : next+4]) // OPT: requester bufsize
ttl := binary.BigEndian.Uint32(pkt[next+4 : next+8]) // OPT: extended-rcode/version/flags
rdlen := int(binary.BigEndian.Uint16(pkt[next+8 : next+10]))
rdata := next + 10
if rdata+rdlen > len(pkt) {
return info
}
if typ == typeOPT {
info.edns.Present = true
info.edns.Bufsize = int(class)
if ttl&ednsDO != 0 {
info.edns.Flags = append(info.edns.Flags, "do")
}
info.ecs = parseECS(pkt[rdata : rdata+rdlen])
return info
}
off = rdata + rdlen
}
return info
}
// parseECS extracts an EDNS Client Subnet option (RFC 7871) as "ip/scope".
func parseECS(rdata []byte) string {
for len(rdata) >= 4 {
code := binary.BigEndian.Uint16(rdata[0:2])
olen := int(binary.BigEndian.Uint16(rdata[2:4]))
if 4+olen > len(rdata) {
return ""
}
if code == optECS && olen >= 4 {
fam := binary.BigEndian.Uint16(rdata[4:6])
srcPrefix := rdata[6]
addrBytes := rdata[8 : 4+olen]
var ip netip.Addr
if fam == 1 {
var b [4]byte
copy(b[:], addrBytes)
ip = netip.AddrFrom4(b)
} else if fam == 2 {
var b [16]byte
copy(b[:], addrBytes)
ip = netip.AddrFrom16(b)
}
if ip.IsValid() {
return ip.String() + "/" + itoa(int(srcPrefix))
}
}
rdata = rdata[4+olen:]
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [4]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// buildResponse assembles the answer, sets TC when a UDP response exceeds the
// negotiated buffer, and appends the OPT RR when the query used EDNS.
func (s *Server) buildResponse(id uint16, pkt []byte, qEnd int, answers []rr, opt *optInfo, transport string, rcode int) []byte {
msg := make([]byte, 12)
binary.BigEndian.PutUint16(msg[0:2], id)
// question is copied verbatim (case preserved) from the query
msg = append(msg, pkt[12:qEnd]...)
body := make([]byte, 0, 512)
for _, a := range answers {
body = append(body, encodeRR(a)...)
}
extra := 0
if opt != nil && opt.edns.Present {
extra = 1
}
flags := uint16(flagQR|flagAA) | (binary.BigEndian.Uint16(pkt[2:4]) & flagRD) | uint16(rcode)
if opt != nil && opt.edns.Present {
body = append(body, buildOPT(opt)...)
}
// UDP truncation: without EDNS the limit is 512; with EDNS it's the
// requester's bufsize (floored at 512). Drop the answer section and set TC.
if transport == "udp" {
limit := udpMaxNoEDNS
if opt != nil && opt.edns.Present && opt.edns.Bufsize > udpMaxNoEDNS {
limit = opt.edns.Bufsize
}
if 12+(qEnd-12)+len(body) > limit {
flags |= flagTC
// Keep only the OPT RR (if any); drop answers.
body = body[:0]
if opt != nil && opt.edns.Present {
body = append(body, buildOPT(opt)...)
answers = nil
} else {
answers = nil
}
}
}
binary.BigEndian.PutUint16(msg[2:4], uint16(flags))
binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT
binary.BigEndian.PutUint16(msg[6:8], uint16(len(answers)))
binary.BigEndian.PutUint16(msg[10:12], uint16(extra))
return append(msg, body...)
}
// encodeRR encodes one answer RR, using a compression pointer (0xC00C) to the
// question name at offset 12.
func encodeRR(a rr) []byte {
var rdata []byte
switch a.typ {
case typeA:
b := a.addr.As4()
rdata = b[:]
case typeAAAA:
b := a.addr.As16()
rdata = b[:]
case typeTXT:
for _, s := range a.txt {
for len(s) > 0 {
n := len(s)
if n > 255 {
n = 255
}
rdata = append(rdata, byte(n))
rdata = append(rdata, s[:n]...)
s = s[n:]
}
}
case typeNS:
rdata = encodeName(a.ns)
}
out := make([]byte, 0, 12+len(rdata))
out = append(out, 0xC0, 0x0C) // name → pointer to question
out = binary.BigEndian.AppendUint16(out, a.typ)
out = binary.BigEndian.AppendUint16(out, classIN)
out = binary.BigEndian.AppendUint32(out, a.ttl)
out = binary.BigEndian.AppendUint16(out, uint16(len(rdata)))
return append(out, rdata...)
}
func encodeName(name string) []byte {
var out []byte
for _, label := range strings.Split(strings.TrimSuffix(name, "."), ".") {
if label == "" {
continue
}
out = append(out, byte(len(label)))
out = append(out, label...)
}
return append(out, 0)
}
// buildOPT emits a minimal EDNS OPT RR echoing our own bufsize (advertise a
// generous 4096) with DO cleared — we serve no DNSSEC.
func buildOPT(*optInfo) []byte {
out := []byte{0} // root name
out = binary.BigEndian.AppendUint16(out, typeOPT)
out = binary.BigEndian.AppendUint16(out, 4096) // our bufsize
out = binary.BigEndian.AppendUint32(out, 0) // ext-rcode/version/flags
out = binary.BigEndian.AppendUint16(out, 0) // rdlen
return out
}
+23 -3
View File
@@ -26,6 +26,9 @@ type Config struct {
// Data plane // Data plane
UDPListen string // ECHOLOT_UDP_LISTEN / --udp-listen (spec default port 8442) UDPListen string // ECHOLOT_UDP_LISTEN / --udp-listen (spec default port 8442)
TCPListen string // ECHOLOT_TCP_LISTEN / --tcp-listen (spec default port 8441) TCPListen string // ECHOLOT_TCP_LISTEN / --tcp-listen (spec default port 8441)
StunListen string // ECHOLOT_STUN_LISTEN / --stun-listen (spec default 3478; empty disables)
DNSListen string // ECHOLOT_DNS_LISTEN / --dns-listen (canary zone; empty disables)
CanaryZone string // ECHOLOT_CANARY_ZONE / --canary-zone (e.g. c.echo-lot.app)
// Admin UI / health listener (spec §7: localhost-only by default) // Admin UI / health listener (spec §7: localhost-only by default)
AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen
@@ -60,11 +63,14 @@ 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.StunListen, "stun-listen", envOr("STUN_LISTEN", ":3478"), "STUN listen address(es), comma-separated; empty disables (spec §4)")
fs.StringVar(&c.DNSListen, "dns-listen", envOr("DNS_LISTEN", ""), "canary-DNS listen address(es) udp+tcp/53, comma-separated; empty disables (spec §6.1)")
fs.StringVar(&c.CanaryZone, "canary-zone", envOr("CANARY_ZONE", ""), "authoritative canary zone, e.g. c.echo-lot.app")
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 +94,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
+147 -4
View File
@@ -7,14 +7,19 @@
package control package control
import ( import (
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"strconv"
"strings" "strings"
"time" "time"
@@ -29,12 +34,23 @@ type Server struct {
Store *store.Store Store *store.Store
Sessions *session.Manager Sessions *session.Manager
Name string Name string
// Targets/capabilities for the profile response. The skeleton offers only // Targets/capabilities for the profile response. The server offers only
// what it actually implements; the registry grows with the code. // what it actually implements; the registry grows with the code.
UDPPort int UDPPort int
TCPPort int TCPPort int
StunPort int
// SPKI pin of the serving cert, for the profile's pins[] field. // SPKI pin of the serving cert, for the profile's pins[] field.
PinB64 string PinB64 string
// Capabilities as computed at startup from what is actually wired up.
Capabilities []string
// TCPRecent returns recent TCP-echo connections for a source IP (may be nil).
TCPRecent func(ip string) any
// DelayedEcho schedules/sends a DELAYED_ECHO for a session (may be nil).
DelayedEcho func(sess *session.Session, actionID string) error
// CanaryQueries returns logged canary lookups for a session prefix (may be nil).
CanaryQueries func(sessionPrefix string) any
// CanaryZone is surfaced in the profile so the app knows what to query.
CanaryZone string
} }
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
@@ -43,11 +59,136 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/profile", s.profile) mux.HandleFunc("GET /v1/profile", s.profile)
mux.HandleFunc("POST /v1/sessions", s.newSession) mux.HandleFunc("POST /v1/sessions", s.newSession)
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession) mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession)
// TODO(spec §5, §6): /v1/sessions/{id}/actions, /v1/sessions/{id}/observations mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations)
// TODO(spec §4): POST /v1/echo, GET /v1/tls-reference mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions)
// TODO(spec §4): POST /v1/echo, GET /v1/tls-reference; TLS-echo/JA4
// TODO(spec §5): downtrain, big_send, frag_send, throughput
return mux return mux
} }
// sessionAuth resolves {id} and requires the bearer to be the owning device.
func (s *Server) sessionAuth(w http.ResponseWriter, r *http.Request) *session.Session {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return nil
}
sess := s.Sessions.ByID(r.PathValue("id"))
if sess == nil || sess.Device != dev.ID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such session"})
return nil
}
return sess
}
// observations is spec §6 — everything the server witnessed for a session.
func (s *Server) observations(w http.ResponseWriter, r *http.Request) {
sess := s.sessionAuth(w, r)
if sess == nil {
return
}
packetsSeen, udp, cb := sess.Observations()
var tcp any
if s.TCPRecent != nil {
// Correlate by source IP: TCP echo carries no session id on the wire.
if ds := sess.DataSource(); ds.IsValid() {
tcp = s.TCPRecent(ds.Addr().Unmap().String())
} else if sess.ControlSource.IsValid() {
tcp = s.TCPRecent(sess.ControlSource.Unmap().String())
}
}
var dnsCanary any
if s.CanaryQueries != nil {
dnsCanary = s.CanaryQueries(sess.ID[:16]) // the session's wire prefix
}
writeJSON(w, http.StatusOK, map[string]any{
"udp": map[string]any{"packets_seen": packetsSeen, "packets": udp},
"tcp": tcp,
"connect_back": cb,
"dns_canary": dnsCanary,
// TODO(spec §6): http echo records
})
}
// actions is spec §5 — authenticated asymmetric operations. Implemented:
// delayed_echo, connect_back. Destination is ALWAYS the session's observed
// source (data-plane source; connect_back uses the control-plane source).
func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
sess := s.sessionAuth(w, r)
if sess == nil {
return
}
var req struct {
Action string `json:"action"`
DelayS int `json:"delay_s"`
Protocol string `json:"protocol"`
Port int `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"})
return
}
actionID := randomID()
switch req.Action {
case "delayed_echo":
if s.DelayedEcho == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "delayed_echo not wired"})
return
}
delay := min(max(req.DelayS, 1), 600)
if !sess.DataSource().IsValid() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "no data-plane traffic seen yet — send an ECHO first"})
return
}
time.AfterFunc(time.Duration(delay)*time.Second, func() {
if err := s.DelayedEcho(sess, actionID); err != nil {
slog.Debug("delayed echo failed", "err", err)
}
})
writeJSON(w, http.StatusAccepted, map[string]any{"action_id": actionID, "delay_s": delay})
case "connect_back":
if req.Port < 1 || req.Port > 65535 || (req.Protocol != "tcp" && req.Protocol != "udp") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connect_back needs protocol tcp|udp and a port"})
return
}
target := net.JoinHostPort(sess.ControlSource.Unmap().String(), strconv.Itoa(req.Port))
go func() {
start := time.Now()
conn, err := net.DialTimeout(req.Protocol, target, 5*time.Second)
res := session.ConnectBackResult{ActionID: actionID, RttMs: float64(time.Since(start).Microseconds()) / 1000}
switch {
case err == nil:
res.Result = "connected"
if req.Protocol == "udp" {
// UDP "dial" always succeeds locally; send one datagram
// so the client actually observes something.
_, _ = conn.Write([]byte("echolot-connect-back " + actionID))
}
conn.Close()
case isTimeout(err):
res.Result = "timeout"
default:
res.Result = "refused"
}
sess.RecordConnectBack(res)
}()
writeJSON(w, http.StatusAccepted, map[string]any{"action_id": actionID, "target": target})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown or unimplemented action"})
}
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
func randomID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func bearer(r *http.Request) string { func bearer(r *http.Request) string {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
if v, ok := strings.CutPrefix(h, "Bearer "); ok { if v, ok := strings.CutPrefix(h, "Bearer "); ok {
@@ -103,15 +244,17 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
// source_url makes GPL §6 compliance mechanical for operators of // source_url makes GPL §6 compliance mechanical for operators of
// modified builds and gives clients provenance for the measurement. // modified builds and gives clients provenance for the measurement.
"source_url": "", // TODO: stamp from build metadata "source_url": "", // TODO: stamp from build metadata
"capabilities": []string{"udp-probe"}, "capabilities": s.Capabilities,
"targets": []map[string]any{{ "targets": []map[string]any{{
"id": s.Name, "id": s.Name,
"ip4": host, // TODO: explicit configured addresses, v6, second STUN addr "ip4": host, // TODO: explicit configured addresses, v6, second STUN addr
"udp_port": s.UDPPort, "udp_port": s.UDPPort,
"tcp_port": s.TCPPort, "tcp_port": s.TCPPort,
"stun_port": s.StunPort,
}}, }},
"pins": []string{"pin-sha256:" + s.PinB64}, "pins": []string{"pin-sha256:" + s.PinB64},
"next_pins": []string{}, "next_pins": []string{},
"canary_zone": s.CanaryZone,
"limits": map[string]any{"max_kbps": 50000, "max_session_s": 900}, "limits": map[string]any{"max_kbps": 50000, "max_session_s": 900},
}) })
} }
+48 -2
View File
@@ -11,9 +11,11 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"fmt"
"log/slog" "log/slog"
"net" "net"
"net/netip" "net/netip"
"sync"
"time" "time"
"echo-lot.app/server/internal/session" "echo-lot.app/server/internal/session"
@@ -27,6 +29,7 @@ const (
TypeEchoResp = 0x02 TypeEchoResp = 0x02
TypeTimesyncReq = 0x07 TypeTimesyncReq = 0x07
TypeTimesyncRsp = 0x08 TypeTimesyncRsp = 0x08
TypeDelayedEcho = 0x0B
) )
type Server struct { type Server struct {
@@ -34,13 +37,22 @@ type Server struct {
// Epoch for server-side t_rx/t_tx: process start; observation consumers // Epoch for server-side t_rx/t_tx: process start; observation consumers
// only need differences plus the timesync exchange, not absolute time. // only need differences plus the timesync exchange, not absolute time.
start time.Time start time.Time
mu sync.Mutex
conns []*net.UDPConn
} }
// Serve runs the read loop for one socket; call once per bound address.
// The socket is retained so actions (delayed echo) can pick a family-matching
// sender later.
func (s *Server) Serve(conn *net.UDPConn) error { func (s *Server) Serve(conn *net.UDPConn) error {
s.mu.Lock()
if s.start.IsZero() {
s.start = time.Now() s.start = time.Now()
}
s.conns = append(s.conns, conn)
s.mu.Unlock()
buf := make([]byte, 65535) buf := make([]byte, 65535)
oob := make([]byte, 0)
_ = oob // TODO: recvmsg w/ IP_RECVTOS+IP_RECVTTL via golang.org/x/net for TTL/DSCP/ECN observation
for { for {
n, raddr, err := conn.ReadFromUDPAddrPort(buf) n, raddr, err := conn.ReadFromUDPAddrPort(buf)
if err != nil { if err != nil {
@@ -51,6 +63,36 @@ func (s *Server) Serve(conn *net.UDPConn) error {
} }
} }
// connFor picks a retained socket whose family matches the target.
func (s *Server) connFor(target netip.AddrPort) *net.UDPConn {
s.mu.Lock()
defer s.mu.Unlock()
want4 := target.Addr().Unmap().Is4()
for _, c := range s.conns {
la := c.LocalAddr().(*net.UDPAddr).AddrPort()
if la.Addr().Unmap().Is4() == want4 {
return c
}
}
return nil
}
// SendDelayedEcho fires one DELAYED_ECHO packet at the session's observed
// data-plane source (spec §5: the NAT-mapping-lifetime primitive). The
// payload carries the action id for correlation.
func (s *Server) SendDelayedEcho(sess *session.Session, actionID string) error {
target := sess.DataSource()
if !target.IsValid() {
return fmt.Errorf("session has no observed data-plane source yet")
}
conn := s.connFor(target)
if conn == nil {
return fmt.Errorf("no data-plane socket matches target family")
}
s.send(conn, target, sess, TypeDelayedEcho, 0, []byte(actionID))
return nil
}
// handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session, // handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session,
// replayed seq → silent drop, never a response. // replayed seq → silent drop, never a response.
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) { func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) {
@@ -80,6 +122,10 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
return return
} }
sess.NoteDataSource(raddr) sess.NoteDataSource(raddr)
sess.RecordUDP(session.UDPObservation{
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
Src: raddr.String(), Size: len(pkt), Type: typ,
})
switch typ { switch typ {
case TypeEchoReq: case TypeEchoReq:
+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)
+72
View File
@@ -31,6 +31,64 @@ type Session struct {
// a bitmask of the 1024 preceding. // a bitmask of the 1024 preceding.
maxSeq uint32 maxSeq uint32
window [16]uint64 window [16]uint64
// Observations (spec §6): per-packet UDP view + connect-back results.
packetsSeen uint64
udpObs []UDPObservation // ring, newest last, cap obsCap
connectBack []ConnectBackResult
}
const obsCap = 4096
// UDPObservation is the server's witnessed view of one data-plane packet.
type UDPObservation struct {
Seq uint32 `json:"seq"`
TRxNs int64 `json:"t_rx_ns"`
TTxNs int64 `json:"t_tx_ns"`
Src string `json:"src"`
Size int `json:"size"`
Type uint8 `json:"type"`
}
// ConnectBackResult records one connect-back action outcome.
type ConnectBackResult struct {
ActionID string `json:"action_id"`
Result string `json:"result"` // connected | refused | timeout
RttMs float64 `json:"rtt_ms"`
}
// RecordUDP appends a packet observation (ring-capped).
func (s *Session) RecordUDP(o UDPObservation) {
s.mu.Lock()
defer s.mu.Unlock()
s.packetsSeen++
if len(s.udpObs) >= obsCap {
s.udpObs = s.udpObs[1:]
}
s.udpObs = append(s.udpObs, o)
}
// RecordConnectBack appends a connect-back outcome.
func (s *Session) RecordConnectBack(r ConnectBackResult) {
s.mu.Lock()
defer s.mu.Unlock()
s.connectBack = append(s.connectBack, r)
}
// Observations returns a copy of everything witnessed so far.
func (s *Session) Observations() (packetsSeen uint64, udp []UDPObservation, cb []ConnectBackResult) {
s.mu.Lock()
defer s.mu.Unlock()
return s.packetsSeen, append([]UDPObservation(nil), s.udpObs...),
append([]ConnectBackResult(nil), s.connectBack...)
}
// DataSource returns the last verified data-plane source (invalid when the
// session has not sent data-plane traffic yet).
func (s *Session) DataSource() netip.AddrPort {
s.mu.Lock()
defer s.mu.Unlock()
return s.dataSource
} }
// KeySalt returns nothing — the salt is not retained after derivation; it is // KeySalt returns nothing — the salt is not retained after derivation; it is
@@ -87,6 +145,20 @@ func (m *Manager) ByWirePrefix(prefix [8]byte) *Session {
return s return s
} }
// ByID resolves a full session id (sessions are keyed by their wire prefix).
func (m *Manager) ByID(id string) *Session {
if len(id) < 16 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
s := m.byPrefix[id[:16]]
if s == nil || s.ID != id || time.Now().After(s.Expires) {
return nil
}
return s
}
func (m *Manager) Delete(id string) { func (m *Manager) Delete(id string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
+266
View File
@@ -0,0 +1,266 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package stun implements an unmodified RFC 5389 STUN binding responder with
// the RFC 5780 NAT-behavior-discovery attributes (OTHER-ADDRESS,
// RESPONSE-ORIGIN, CHANGE-REQUEST) when alternate addresses are available.
// No custom framing — interop with existing STUN tooling is a feature
// (spec §4). Each configured primary address gets two sockets: the given
// port and port+1 (the RFC 5780 alternate-port convention).
package stun
import (
"crypto/rand"
"encoding/binary"
"log/slog"
"net"
"net/netip"
)
const (
magicCookie = 0x2112A442
typeBindingRequest = 0x0001
typeBindingSuccess = 0x0101
attrChangeRequest = 0x0003
attrXorMapped = 0x0020
attrSoftware = 0x8022
attrResponseOrigin = 0x802B
attrOtherAddress = 0x802C
changeIP = 0x04
changePort = 0x02
)
// sock is one bound socket, addressable by (address index, port index).
type sock struct {
conn *net.UDPConn
addr netip.AddrPort
}
// Server holds the socket grid: addrs × {primary, alternate} ports.
type Server struct {
// socks[i][0] = primary port, socks[i][1] = alt port for address i.
socks [][2]*sock
}
// Listen binds primary+alternate sockets for every address. Addresses are
// "ip:port" specs; the alternate port is port+1.
func Listen(addrs []string) (*Server, error) {
s := &Server{}
for _, spec := range addrs {
ap, err := netip.ParseAddrPort(spec)
if err != nil {
return nil, err
}
var pair [2]*sock
for i, port := range []uint16{ap.Port(), ap.Port() + 1} {
bind := netip.AddrPortFrom(ap.Addr(), port)
conn, err := net.ListenUDP("udp", net.UDPAddrFromAddrPort(bind))
if err != nil {
s.Close()
return nil, err
}
pair[i] = &sock{conn: conn, addr: bind}
}
s.socks = append(s.socks, pair)
}
return s, nil
}
func (s *Server) Close() {
for _, pair := range s.socks {
for _, sk := range pair {
if sk != nil {
sk.conn.Close()
}
}
}
}
// Has5780 reports whether any address family has ≥2 addresses — the
// prerequisite for full NAT behavior discovery.
func (s *Server) Has5780() bool {
var v4, v6 int
for _, pair := range s.socks {
if pair[0].addr.Addr().Is4() || pair[0].addr.Addr().Is4In6() {
v4++
} else {
v6++
}
}
return v4 >= 2 || v6 >= 2
}
// Serve starts one read loop per socket and blocks until the first error.
func (s *Server) Serve() error {
errCh := make(chan error, len(s.socks)*2)
for ai := range s.socks {
for pi := range s.socks[ai] {
go func(ai, pi int) { errCh <- s.loop(ai, pi) }(ai, pi)
}
}
return <-errCh
}
func (s *Server) loop(ai, pi int) error {
sk := s.socks[ai][pi]
buf := make([]byte, 1500)
for {
n, raddr, err := sk.conn.ReadFromUDPAddrPort(buf)
if err != nil {
return err
}
s.handle(ai, pi, buf[:n], raddr)
}
}
// otherAddr finds the "diagonal" alternate for RFC 5780: different address
// (same family), different port. Returns nil when there is none.
func (s *Server) other(ai int, sameFamily bool, fam4 bool) int {
for i, pair := range s.socks {
if i == ai {
continue
}
is4 := pair[0].addr.Addr().Is4() || pair[0].addr.Addr().Is4In6()
if !sameFamily || is4 == fam4 {
return i
}
}
return -1
}
func (s *Server) handle(ai, pi int, pkt []byte, raddr netip.AddrPort) {
if len(pkt) < 20 || binary.BigEndian.Uint16(pkt[0:2]) != typeBindingRequest {
return
}
if binary.BigEndian.Uint32(pkt[4:8]) != magicCookie {
return
}
msgLen := int(binary.BigEndian.Uint16(pkt[2:4]))
if 20+msgLen > len(pkt) {
return
}
var txid [12]byte
copy(txid[:], pkt[8:20])
// Parse CHANGE-REQUEST if present (RFC 5780 §7.2).
var change byte
for off := 20; off+4 <= 20+msgLen; {
at := binary.BigEndian.Uint16(pkt[off : off+2])
al := int(binary.BigEndian.Uint16(pkt[off+2 : off+4]))
if off+4+al > len(pkt) {
break
}
if at == attrChangeRequest && al >= 4 {
change = pkt[off+7]
}
off += 4 + al + (4-al%4)%4 // attributes are 32-bit aligned
}
// Pick the responding socket per CHANGE-REQUEST.
fam4 := raddr.Addr().Is4() || raddr.Addr().Is4In6()
rai, rpi := ai, pi
if change&changeIP != 0 {
if o := s.other(ai, true, fam4); o >= 0 {
rai = o
} else {
return // cannot honor — RFC says error response; silence is safer for a probe target
}
}
if change&changePort != 0 {
rpi = 1 - pi
}
responder := s.socks[rai][rpi]
resp := buildResponse(txid, raddr, responder.addr, s.otherAddress(ai, fam4))
if _, err := responder.conn.WriteToUDPAddrPort(resp, raddr); err != nil {
slog.Debug("stun write failed", "to", raddr, "err", err)
}
}
// otherAddress computes the OTHER-ADDRESS attribute value (alt IP, alt port)
// for the client's family, or an invalid AddrPort when unavailable.
func (s *Server) otherAddress(ai int, fam4 bool) netip.AddrPort {
if o := s.other(ai, true, fam4); o >= 0 {
return s.socks[o][1].addr
}
return netip.AddrPort{}
}
func buildResponse(txid [12]byte, mapped, origin, other netip.AddrPort) []byte {
attrs := xorMappedAttr(attrXorMapped, mapped, txid)
attrs = append(attrs, addrAttr(attrResponseOrigin, origin)...)
if other.IsValid() {
attrs = append(attrs, addrAttr(attrOtherAddress, other)...)
}
sw := []byte("echolot")
attrs = append(attrs, attrHeader(attrSoftware, len(sw))...)
attrs = append(attrs, pad4(sw)...)
msg := make([]byte, 20, 20+len(attrs))
binary.BigEndian.PutUint16(msg[0:2], typeBindingSuccess)
binary.BigEndian.PutUint16(msg[2:4], uint16(len(attrs)))
binary.BigEndian.PutUint32(msg[4:8], magicCookie)
copy(msg[8:20], txid[:])
return append(msg, attrs...)
}
func attrHeader(typ uint16, valLen int) []byte {
h := make([]byte, 4)
binary.BigEndian.PutUint16(h[0:2], typ)
binary.BigEndian.PutUint16(h[2:4], uint16(valLen))
return h
}
func pad4(b []byte) []byte {
for len(b)%4 != 0 {
b = append(b, 0)
}
return b
}
// addrValue encodes the RFC 5389 address structure (family, port, address).
func addrValue(ap netip.AddrPort) []byte {
addr := ap.Addr().Unmap()
if addr.Is4() {
v := make([]byte, 8)
v[1] = 0x01
binary.BigEndian.PutUint16(v[2:4], ap.Port())
a4 := addr.As4()
copy(v[4:], a4[:])
return v
}
v := make([]byte, 20)
v[1] = 0x02
binary.BigEndian.PutUint16(v[2:4], ap.Port())
a16 := addr.As16()
copy(v[4:], a16[:])
return v
}
func addrAttr(typ uint16, ap netip.AddrPort) []byte {
v := addrValue(ap)
return append(attrHeader(typ, len(v)), v...)
}
// xorMappedAttr encodes XOR-MAPPED-ADDRESS (RFC 5389 §15.2).
func xorMappedAttr(typ uint16, ap netip.AddrPort, txid [12]byte) []byte {
v := addrValue(ap)
binary.BigEndian.PutUint16(v[2:4], ap.Port()^uint16(magicCookie>>16))
var key [16]byte
binary.BigEndian.PutUint32(key[0:4], magicCookie)
copy(key[4:], txid[:])
for i := 4; i < len(v); i++ {
v[i] ^= key[i-4]
}
return append(attrHeader(typ, len(v)), v...)
}
// NewTxID is exported for tests and client code.
func NewTxID() [12]byte {
var t [12]byte
_, _ = rand.Read(t[:])
return t
}
+137
View File
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package stun
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"time"
)
// bindingRequest builds a minimal RFC 5389 binding request.
func bindingRequest(txid [12]byte, change byte) []byte {
var attrs []byte
if change != 0 {
attrs = append(attrs, attrHeader(attrChangeRequest, 4)...)
attrs = append(attrs, 0, 0, 0, change)
}
msg := make([]byte, 20, 20+len(attrs))
binary.BigEndian.PutUint16(msg[0:2], typeBindingRequest)
binary.BigEndian.PutUint16(msg[2:4], uint16(len(attrs)))
binary.BigEndian.PutUint32(msg[4:8], magicCookie)
copy(msg[8:20], txid[:])
return append(msg, attrs...)
}
// parseXorMapped extracts XOR-MAPPED-ADDRESS from a binding success.
func parseXorMapped(t *testing.T, resp []byte, txid [12]byte) netip.AddrPort {
t.Helper()
if binary.BigEndian.Uint16(resp[0:2]) != typeBindingSuccess {
t.Fatalf("type = %#x, want binding success", resp[0:2])
}
msgLen := int(binary.BigEndian.Uint16(resp[2:4]))
for off := 20; off+4 <= 20+msgLen; {
at := binary.BigEndian.Uint16(resp[off : off+2])
al := int(binary.BigEndian.Uint16(resp[off+2 : off+4]))
if at == attrXorMapped {
v := append([]byte(nil), resp[off+4:off+4+al]...)
port := binary.BigEndian.Uint16(v[2:4]) ^ uint16(magicCookie>>16)
var key [16]byte
binary.BigEndian.PutUint32(key[0:4], magicCookie)
copy(key[4:], txid[:])
for i := 4; i < len(v); i++ {
v[i] ^= key[i-4]
}
if v[1] == 0x01 {
return netip.AddrPortFrom(netip.AddrFrom4([4]byte(v[4:8])), port)
}
return netip.AddrPortFrom(netip.AddrFrom16([16]byte(v[4:20])), port)
}
off += 4 + al + (4-al%4)%4
}
t.Fatal("no XOR-MAPPED-ADDRESS in response")
return netip.AddrPort{}
}
func TestBindingAndChangePort(t *testing.T) {
// Two loopback "addresses" is not possible portably, so exercise one
// address (basic binding + change-port); the change-IP path needs the
// two-address grid of a real deployment.
srv, err := Listen([]string{"127.0.0.1:0"})
if err != nil {
t.Fatal(err)
}
// port 0 twice would collide at 1; rebind explicitly on free ports
srv.Close()
base := freePort(t)
srv, err = Listen([]string{netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), base).String()})
if err != nil {
t.Skipf("cannot bind %d/%d: %v", base, base+1, err)
}
defer srv.Close()
go srv.Serve()
client, err := net.DialUDP("udp", nil, srv.socks[0][0].conn.LocalAddr().(*net.UDPAddr))
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(2 * time.Second))
txid := NewTxID()
client.Write(bindingRequest(txid, 0))
buf := make([]byte, 1500)
n, err := client.Read(buf)
if err != nil {
t.Fatal(err)
}
mapped := parseXorMapped(t, buf[:n], txid)
want := client.LocalAddr().(*net.UDPAddr).AddrPort()
if mapped.Port() != want.Port() {
t.Fatalf("mapped port %d, want %d", mapped.Port(), want.Port())
}
// CHANGE-REQUEST(port): response must come from the alternate port.
// Dial-connected sockets drop packets from other sources, so use an
// unconnected socket and inspect the reply's source.
uc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer uc.Close()
uc.SetDeadline(time.Now().Add(2 * time.Second))
txid2 := NewTxID()
uc.WriteToUDPAddrPort(bindingRequest(txid2, changePort), srv.socks[0][0].addr)
n, from, err := uc.ReadFromUDPAddrPort(buf)
if err != nil {
t.Fatal(err)
}
if from.Port() != srv.socks[0][1].addr.Port() {
t.Fatalf("change-port reply came from %v, want alt port %d", from, srv.socks[0][1].addr.Port())
}
parseXorMapped(t, buf[:n], txid2)
}
func freePort(t *testing.T) uint16 {
t.Helper()
// Find two adjacent free ports for the primary/alternate pair.
for tries := 0; tries < 20; tries++ {
l, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
continue
}
p := l.LocalAddr().(*net.UDPAddr).Port
l.Close()
l2, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: p + 1})
if err != nil {
continue
}
l2.Close()
return uint16(p)
}
t.Skip("no adjacent free UDP ports found")
return 0
}
+74 -16
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} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return 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
} }
+91
View File
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package tcpecho implements the spec §4 TCP echo: after connect the server
// sends one JSON line with what it observed (source address/port, negotiated
// MSS and TCP options from TCP_INFO), then byte-echoes until FIN. This is the
// evidence source for mtu.mss_observed.
//
// The TLS/ALPN "elt-echo" variant (ClientHello capture + JA4) is not
// implemented yet.
package tcpecho
import (
"encoding/json"
"io"
"net"
"sync"
"time"
)
// ConnRecord is what the observations API reports per connection (spec §6).
type ConnRecord struct {
ConnectedAt time.Time `json:"connected_at"`
Src string `json:"src"`
MSS int `json:"mss"`
Options []string `json:"options"`
}
type Server struct {
mu sync.Mutex
recent []ConnRecord // ring, newest last
}
const recentCap = 1024
func (s *Server) record(r ConnRecord) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.recent) >= recentCap {
s.recent = s.recent[1:]
}
s.recent = append(s.recent, r)
}
// RecentFor returns records whose source IP matches ip.
func (s *Server) RecentFor(ip string) []ConnRecord {
s.mu.Lock()
defer s.mu.Unlock()
var out []ConnRecord
for _, r := range s.recent {
if h, _, err := net.SplitHostPort(r.Src); err == nil && h == ip {
out = append(out, r)
}
}
return out
}
func (s *Server) Serve(ln net.Listener) error {
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Minute))
info := tcpInfo(conn) // platform-specific; zero values off-Linux
rec := ConnRecord{
ConnectedAt: time.Now().UTC(),
Src: conn.RemoteAddr().String(),
MSS: info.MSS,
Options: info.Options,
}
s.record(rec)
greeting, _ := json.Marshal(map[string]any{
"observed_src": rec.Src,
"mss": rec.MSS,
"options": rec.Options,
})
if _, err := conn.Write(append(greeting, '\n')); err != nil {
return
}
// Byte-echo until FIN; the client's data is its own to interpret.
_, _ = io.Copy(conn, conn)
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux
package tcpecho
import (
"encoding/binary"
"net"
"syscall"
"unsafe"
)
type connInfo struct {
MSS int
Options []string
}
// tcpInfo reads TCP_INFO via getsockopt. Only the head of struct tcp_info is
// needed: 8 header bytes (state..wscale flags) then u32 rto, ato, snd_mss,
// rcv_mss — layout is part of the kernel ABI and stable.
func tcpInfo(conn net.Conn) connInfo {
tc, ok := conn.(*net.TCPConn)
if !ok {
return connInfo{}
}
raw, err := tc.SyscallConn()
if err != nil {
return connInfo{}
}
var buf [104]byte
var got bool
_ = raw.Control(func(fd uintptr) {
l := uint32(len(buf))
_, _, errno := syscall.Syscall6(syscall.SYS_GETSOCKOPT, fd,
uintptr(syscall.SOL_TCP), uintptr(syscall.TCP_INFO),
uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&l)), 0)
got = errno == 0 && l >= 24
})
if !got {
return connInfo{}
}
// tcpi_options bit flags (include/uapi/linux/tcp.h)
const (
optTimestamps = 1
optSACK = 2
optWscale = 4
optECN = 8
)
var opts []string
ob := buf[5]
if ob&optTimestamps != 0 {
opts = append(opts, "timestamps")
}
if ob&optSACK != 0 {
opts = append(opts, "sack")
}
if ob&optWscale != 0 {
opts = append(opts, "wscale")
}
if ob&optECN != 0 {
opts = append(opts, "ecn")
}
return connInfo{
MSS: int(binary.LittleEndian.Uint32(buf[16:20])), // tcpi_snd_mss
Options: opts,
}
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !linux
package tcpecho
import "net"
type connInfo struct {
MSS int
Options []string
}
// tcpInfo: TCP_INFO is Linux-only; other platforms report zero values and
// the greeting says mss:0 — honest absence rather than a guess.
func tcpInfo(net.Conn) connInfo { return connInfo{} }