Skip to content

Repository files navigation

Pingway

Continuous network path monitor. One Docker container that pings your LAN, gateway, ISP, and internet anchors every second, runs scheduled speed tests, detects and logs outages, and serves a realtime dashboard plus a kiosk display mode — so you can answer "is my internet ok right now, and where is the problem?" at a glance, with history.

  • Path visualization — [You] → LAN → ISP → Internet, with live RTT and per-hop health. If tier 1 is green and tier 3 is red, the problem is upstream, and the UI says so.
  • 1-second ICMP monitoring with explicit outage events (start, end, duration) — not just gaps in a chart.
  • Scheduled speed tests (download, upload, idle + loaded latency) with pluggable engines: LibreSpeed (default), Cloudflare, Ookla.
  • Synthetic call probe — an RTP-shaped UDP stream to an off-network reflector, measuring the thing you actually complain about: call freezes, jitter, and a MOS score in the ISP's own quality units. Use the free public reflectors or run your own. See docs/call-probe.md.
  • Realtime dashboard over SSE, uPlot history charts, tiered retention (raw 48h → 1-minute 30d → 1-hour forever) in a single SQLite file.
  • /kiosk route for a dedicated always-on display (Raspberry Pi).
  • Single static Go binary, frontend embedded. Multi-arch images (amd64/arm64). No external database. Zero config required.

Quick start

mkdir -p data && sudo chown 65532:65532 data   # image runs as nonroot (uid 65532)

docker run -d --name pingway \
  --network host \
  --cap-add NET_RAW \
  -v "$PWD/data:/data" \
  --restart unless-stopped \
  ghcr.io/jasondcamp/pingway:latest

Open http://<host>:8080. With zero config, pingway auto-detects your default gateway (tier 1) and monitors 1.1.1.1 and 8.8.8.8 (tier 3). Add your own targets in Settings.

Or with compose: see docker-compose.yml.

Networking notes

  • --network host is recommended: pings originate from the host network namespace, so latency is true and LAN devices are reachable.
  • Bridge mode works too (-p 8080:8080 instead of --network host): every RTT gains a small constant from NAT traversal and the container can only reach LAN devices routable from the Docker bridge. Fine for "is the internet up", less precise for LAN diagnosis.
  • ICMP mode: the image runs as a non-root user, so it uses unprivileged UDP ping by default (Docker enables this via net.ipv4.ping_group_range); results are equivalent for monitoring purposes. For privileged raw-socket ICMP, run with --cap-add NET_RAW --user 0 — a non-root process cannot use the capability on its own. The active mode is logged at startup and shown in /api/status.

Configuration

Everything is optional. Precedence: UI edits (persisted to DB) > env vars > YAML file > defaults. YAML/env are read once at startup and upserted; after that the DB is the source of truth — unless you set CONFIG_LOCK=true, which makes env/yaml always win and disables UI edits.

Environment variables

Variable Default Meaning
TARGETS (auto) Name:host[:tier][:probe],... e.g. Router:192.168.1.1:1,CF:1.1.1.1:ping,CF DNS:1.1.1.1:dns — after host, a number 1-3 is the tier (default 3 = internet) and a name is the probe: ping (default; alias icmp) or dns, which sends a real recursive query (A example.com, UDP :53) and times the answer
LISTEN :8080 HTTP listen address
DATA_DIR /data SQLite + downloaded tools location
PING_INTERVAL_MS 1000 Ping cadence
PING_TIMEOUT_MS 2000 Ping timeout
SPEEDTEST_ENGINE librespeed librespeed | cloudflare | ookla
SPEEDTEST_INTERVAL_MINUTES 45 ±10% jitter applied
SPEEDTEST_ENABLED true
OOKLA_ACCEPT_EULA false Allow downloading the Ookla CLI (see below)
CONFIG_LOCK false env/yaml win; UI settings become read-only
LOG_FORMAT (auto) json or text
CONFIG_FILE /config/config.yaml YAML config path
LIBRESPEED_SERVER (auto) Pin a LibreSpeed backend base URL
CALLPROBE_REFLECTORS (unset) Name:host[:port],... — enables the synthetic call probe; free public reflectors below
CALLPROBE_PPS 50 Call-probe packets/sec per reflector (~9KB/s each way)

YAML

Mount a config file at /config/config.yaml — see config.example.yaml for all fields (targets, tiers, thresholds, retention).

Target tiers

  • Tier 1 — LAN infrastructure: router, mesh hub, security appliance.
  • Tier 2 — ISP first hop / CMTS gateway (find it with traceroute 1.1.1.1, first non-private hop). Prefer this over pinging your own public IP: from inside the LAN that ping usually hairpins at your router's WAN interface and never traverses the ISP, so it can look healthy while your line is down.
  • Tier 3 — internet anchors (1.1.1.1, 8.8.8.8, …). An internet outage is recorded when all tier 3 targets are down simultaneously.

Gotchas

  • Sub-5ms DNS query times? Your router is answering them. Many routers and firewalls (Firewalla's DNS Booster, Pi-hole, dnsmasq-based gear) transparently intercept port 53 and answer from a local cache — regardless of which server the query was addressed to. The tells: a dns target with an RTT well below the ICMP RTT to the same host (a genuine answer can't arrive faster than the round trip), or DNS targets staying green through an internet outage (cached names still resolve). Confirm with dig @1.1.1.1 id.server CH TXT +short — real Cloudflare returns its PoP code (e.g. EWR); an empty answer means something in between is impersonating the resolver. Fix: exclude the pingway host from your router's DNS interception/caching feature, or read the target as "LAN DNS service health" rather than resolver reachability.

Speed test engines

Engine Notes
librespeed Default. Native implementation against public LibreSpeed servers; fully open. Pin your own server with LIBRESPEED_SERVER.
cloudflare Uses speed.cloudflare.com __down/__up endpoints. Not an official API — may break or rate-limit at any time; failures are recorded, the scheduler keeps running.
ookla Executes the official speedtest CLI. Not bundled (EULA). Set OOKLA_ACCEPT_EULA=true and pingway downloads it to the data volume on first use — doing so accepts Ookla's EULA.

Throughput is measured honestly: fixed 10s transfers, first 2s ramp-up discarded, computed from bytes moved in the steady-state window. While a test runs, concurrent ping samples are flagged during_speedtest, and loaded latency (bufferbloat) is derived from them when the engine doesn't provide its own.

Failed runs are stored with their error string — they're data, not noise. Scheduled runs are skipped (and marked skipped_outage) while an internet-level outage is active.

Synthetic call probe

ICMP proves loss exists; an RTP-shaped UDP stream to an off-network reflector proves calls fail — freezes counted in milliseconds, jitter, and a MOS score in the telecom industry's own quality units. The far end is a reflector: a tiny stateless UDP echo running outside your network, so every probe packet crosses the exact path that breaks.

Public reflectors

Free, no signup, best-effort (rate-limited per source IP):

Region Host
New York (nyc3) 01.reflector.nyc3.pingway.net
San Francisco (sfo3) 01.reflector.sfo3.pingway.net
Amsterdam (ams3) 01.reflector.ams3.pingway.net

Pick one near you and one far away — near/far separation is what tells "my access link is broken" apart from "the internet is having a day":

CALLPROBE_REFLECTORS=NYC3:01.reflector.nyc3.pingway.net,AMS3:01.reflector.ams3.pingway.net

Run your own

For guaranteed capacity, or a far end you fully control. The reflector is safe to run on a public IP: an anti-spoofing HMAC handshake means a spoofed source never earns a single echo, replies are byte-for-byte the request (no amplification), and it rate-limits per source and globally. Bandwidth is ~9KB/s each way per monitor.

docker run -d --name pingway-reflector --restart unless-stopped \
  -p 15000:15000/udp ghcr.io/jasondcamp/pingway-reflector:latest

Full guide (k8s manifest, security model, tuning flags): docs/call-probe.md.

HTTP API

GET  /api/status            live per-target state, loss, ping mode, outages
GET  /api/targets           CRUD (POST /api/targets, PUT/DELETE /api/targets/{id})
GET  /api/ping?target=&from=&to=&resolution=raw|1m|1h   (auto-selects by range)
GET  /api/speedtests?from=&to=
POST /api/speedtest/run     manual trigger (409 while one is running)
GET  /api/outages?from=&to=&target=
GET  /api/callprobe/history?from=&to=      per-second MOS/loss/jitter buckets
GET  /api/callprobe/freezes?from=&to=&min_ms=   freeze events + totals
GET  /api/reflectors        configured call-probe reflectors
GET  /api/summary?range=1h|6h|24h|7d|30d
GET  /api/stream            SSE: ping (1s batches), status, speedtest, callprobe, freeze
GET  /api/export?format=csv&table=ping_samples|ping_rollup_1m|ping_rollup_1h|speedtest_results|outage_events|targets
GET  /healthz               liveness (pinger goroutines + DB writable)

All timestamps are unix milliseconds UTC.

Security

Pingway ships without auth and assumes a trusted LAN. To expose it further, put it behind a reverse proxy that terminates auth, e.g. Caddy:

pingway.example.com {
    basic_auth {
        me $2a$14$...hash...
    }
    reverse_proxy 127.0.0.1:8080
}

(or Authelia/OAuth2-proxy/Tailscale Serve — anything that fronts HTTP).

Raspberry Pi

Works well on a Pi 4/5 with the 64-bit OS (arm64 image; RSS well under 100MB). Use ethernet if you can: on WiFi, speed tests measure the Pi's radio, not your line.

Kiosk display recipe

  1. Run the container on the Pi (or anywhere on the LAN).
  2. Run the copy-paste script:
curl -fsSL https://raw.githubusercontent.com/jasondcamp/pingway/main/contrib/pi-kiosk-setup.sh | bash -s -- http://localhost:8080/kiosk

It installs Chromium if needed, disables screen blanking, and installs a systemd user unit that launches Chromium in kiosk mode on boot (Wayland labwc/wayfire and X11 both work). Query params: ?theme=dark|light, ?scale=1.2.

The kiosk shows the path visualization, live latency and loss for your primary internet anchor, and the last speed test. If the pingway backend becomes unreachable, the display switches to an unmissable MONITOR OFFLINE state so a dead monitor is never mistaken for a dead internet connection.

Development

make frontend   # npm install + vite build (once, and after frontend changes)
make run        # go build + run with ./data, text logs
make test       # go vet + go test -race ./...
make docker     # local image build

The frontend is plain TypeScript + uPlot, built with Vite and embedded into the Go binary via embed.FS. Backend is Go with pure-Go SQLite (modernc.org/sqlite, WAL mode) — no CGO anywhere, so cross-compilation is trivial.

Run the LibreSpeed integration test (hits real servers):

go test -tags=integration -run TestLibreSpeedIntegration ./internal/speedtest -v

Architecture

One binary, a handful of supervised goroutines (panics are recovered and restarted with backoff — a silently dead pinger is the worst failure mode a monitor can have):

  • Pinger — one drift-free 1s loop per target (injected ping function; pro-bing in production). Samples fan out to the writer, outage detector, and SSE hub.
  • Writer — the only goroutine that writes ping samples; batches inserts into one transaction every ~1.5s (your Pi's SD card thanks you).
  • OutageDetector — pure per-target state machine: N consecutive failures opens an outage event, M consecutive successes closes it. Open events survive restarts.
  • Aggregator — every 5 min: incremental 1m/1h rollups (p95, jitter) with cursors in settings, then retention pruning.
  • SpeedTester — jittered scheduler + engine interface; manual trigger via the API.
  • SSE hub — per-client buffered channels; a slow client is dropped, never blocks the broadcaster.

License

See LICENSE.

About

Local network insights for ISP diagnosis and speed testing

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages