ip-probe — InternetPressure.io probe agent
Single static Go binary that measures HTTP / DNS / ICMP / traceroute against the targets assigned by the
ingestion API and ships signed, gzip-compressed batches. Implements docs/PROBE-PROTOCOL.md (v1) exactly.
services/probe-agent/
├── main.go, agent.go, once.go CLI + wiring (run / once / check-config / --version)
├── internal/
│ ├── protocol/ wire types (config, batch, measurement, traceroute, health, error codes)
│ ├── signer/ HMAC-SHA256 request signature (+ test vectors, see below)
│ ├── config/ probe.yaml (+ IP_PROBE_* env) — flat YAML subset parser, no third-party dependency
│ ├── client/ signed HTTP client (GET /config, POST /batch, GET /agent/latest) + clock offset EWMA
│ ├── sched/ priority timer wheel: intervals, jitter, spread, boost, concurrency + per-host limits
│ ├── checks/http httptrace timings, fresh connection, no redirects, 16 KiB body cap, error mapping
│ ├── checks/dns one A query per resolver (system resolver or miekg/dns UDP + TCP on truncation)
│ ├── checks/ping unprivileged ICMP echo (udp4/udp6) with tcp-connect fallback; also the "tcp" check
│ ├── checks/traceroute system traceroute binary + parser (macOS / Linux) + route_hash
│ ├── batcher/ queue → JSON → gzip → signed POST; health ≤ 1/min; spool + backoff 5 s → 5 min
│ ├── spool/ data_dir/spool/<unixnano>.json.gz, 200 MiB cap, oldest first
│ ├── identity/ ipinfo.io → ip-api.com (public IP, ASN, org, country, city, 2-decimal lat/lon)
│ ├── health/ counters, /healthz, /metrics (Prometheus text)
│ └── update/ optional self-update (sha256-verified, atomic rename, exit 0)
├── deploy/launchd/io.internetpressure.probe.plist · deploy/systemd/ip-probe.service · deploy/install.sh
├── Makefile · VERSION · probe.example.yaml
└── dist/ (make build) ip-probe-darwin-arm64, ip-probe-linux-amd64, SHA256SUMSDependencies: Go standard library, github.com/miekg/dns, golang.org/x/net (icmp, ipv4, ipv6). Nothing else.
Build & test
make build # CGO_ENABLED=0, -trimpath, -ldflags "-s -w -X main.version=$(cat VERSION)" → dist/
make test # go test ./...
make lint # gofmt + go vet
make once T=www.cloudflare.com # build for this host and run every check once (no server needed)Commands
| Command | Purpose |
|---|---|
ip-probe run [--config F] |
Run the agent (default command). |
ip-probe once --target HOST [--url U] [--ip IP] [--port N] [--no-traceroute] [--no-ping] |
Run http + dns (all resolvers) + ping + traceroute once and print JSON. Works without a config file or server. |
ip-probe check-config [--config F] |
Validate the configuration, print it with the key redacted. |
ip-probe --version |
ip-probe 0.1.0 (darwin/arm64, go1.26.3) |
Config lookup order: --config, $IP_PROBE_CONFIG, /etc/internetpressure/probe.yaml, ~/.internetpressure/probe.yaml.
Configuration (probe.yaml)
probe_id: ca-qc-01
key: <64 hex chars> # HMAC key issued by the admin API
ingest_url: https://www.internetpressure.io/ingest/v1
data_dir: /var/lib/internetpressure # spool/ + config.json (last good config) + update state; 0700
listen: 127.0.0.1:9381 # local /healthz + /metrics
log_level: info # debug | info | warn | error
allow_self_update: true
resolvers_override: [] # optional: ["system", "google=8.8.8.8:53", "1.1.1.1"]
max_concurrency: 8 # global cap on concurrent checksEvery key can be overridden by IP_PROBE_<UPPER_KEY> (lists comma-separated), e.g.
IP_PROBE_PROBE_ID=ca-qc-01 IP_PROBE_KEY=… ip-probe run. The file is a flat YAML document (scalars, inline or
block lists, comments) parsed without a YAML library — nested mappings are rejected.
Logs go to stderr: human-readable text on a terminal, JSON lines otherwise (launchd / journald).
How signing works
Every request carries X-IP-Probe, X-IP-Timestamp (Unix seconds) and X-IP-Signature:
canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + sha256_hex(body_bytes_as_sent)
signature = lowercase_hex( HMAC-SHA256( hex_decode(key), canonical ) )PATHis the request path without query string (/ingest/v1/batch).- For
POST /batchthe body is gzip → the hash covers the compressed bytes exactly as sent. Spooled batches are the compressed bytes, so a retry re-signs with a fresh timestamp without touching the payload. - For GET the body hash is
sha256_hex("")=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. - The timestamp is the local clock corrected by the estimated offset (
clock_offset_ms, see below), so a probe with a drifting clock still passes the ±300 s window. On401 … skew …the agent resyncs from the responseDateheader and retries the batch once.
Test vector (also in internal/signer/signer_test.go; computed independently with Python hmac)
| key (hex) | 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f |
| probe_id | ca-qc-01 |
| timestamp | 1789189804 (2026-09-12T05:10:04Z) |
| POST path | /ingest/v1/batch |
| body (raw, not gzipped for the vector) | {"probe_id":"ca-qc-01","agent_version":"0.1.0","measurements":[]} |
| sha256(body) | 9fd6962cedcf4a7aedb13c38f7f63137fdbbbf3703b447ebd761234981847c65 |
| canonical | POST\n/ingest/v1/batch\n1789189804\n9fd6962c…847c65 |
| signature | 111075ee8d4c20723598c8f75d5ad89433f0385ff965f123302d2b9411280723 |
| GET path | /ingest/v1/config (body empty) |
| canonical | GET\n/ingest/v1/config\n1789189804\ne3b0c442…52b855 |
| signature | 3798599e7f1bed2dab170d2aacf5f5288ec7cc591a64bd37285d3ea3c3450a8b |
Python reference:
import hmac, hashlib
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
canonical = f"POST\n/ingest/v1/batch\n1789189804\n{hashlib.sha256(body).hexdigest()}".encode()
hmac.new(key, canonical, hashlib.sha256).hexdigest() # == 111075ee…0723Behaviour summary
- Start-up: load config → start
/healthz→ applydata_dir/config.json(last good config, offline start) →GET /configwith backoff 5 s → 5 min until it succeeds (warn, never fatal) → refresh everyconfig_refresh_seconds(min 30 s) or immediately when a batch response carries anotherconfig_version. Target adds/removes/edits and interval changes apply live. - Scheduler: single timer wheel.
httpeverytiers[tier],dnseverydns_every(one query per resolver, sequential),pingeveryping_every,tracerouteeverytraceroute_every. ±10 % jitter, first run uniformly spread over one interval,boostfactor honoured untiluntil, floor of 10 s (server values below are clamped and logged). Global limitmax_concurrency, never two checks on the same hostname at once, never two traceroutes at once. Panics in a check are recovered and counted. - http: fresh connection (keep-alive disabled, no proxy, HTTP/2 attempted),
httptracegivesdns_ms,tcp_ms,tls_ms;ttfb_ms= start → first byte,total_ms= start → body (≤ 16 KiB) read. Redirects are not followed (3xx isok).ok = connected ∧ TLS ok ∧ status < 500; 4xx keepsok=truewitherror="http_4xx", 5xx →ok=false, error="http_5xx". Pinnedipis dialed while the hostname stays in SNI andHost. Error mapping:dns_fail,tcp_timeout,tcp_refused,tcp_reset,tls_fail,tls_cert,http_timeout,reset,other. - dns:
system→ OS resolver; others → UDP A query, EDNS0 1232, 3 s, retried once over TCP only when truncated.ok = NOERROR ∧ ≥1 answer; answers sorted. - ping: 5 echoes, 200 ms apart, 2 s each, through an unprivileged ICMP datagram socket. If the socket cannot
be opened the measurement becomes kind
tcp(5 sequential connects toport) witherror="icmp_unavailable"when successful,unreachablewhen nothing answered.jitter_ms= mean absolute successive difference. - traceroute:
traceroute -n -q 1 -w 2 -m 30 <ip>(60 s cap), hops with*,route_hash= sha1 ofip1|ip2|*|…,reachedwhen the last hop is the destination,total_ms= RTT of the last answered hop. - Batching: flush every
batch_flush_secondsor atmax_batch; health block at most once per minute. Network error / 5xx / 429 → gzip batch spooled todata_dir/spool/, backoff 5 s → 5 min, oldest first when the server is back. 200 MiB spool cap drops the oldest files. Other 4xx → dropped and logged. - Shutdown (SIGTERM/SIGINT): stop scheduling, wait for in-flight checks, one final POST with a 5 s timeout, spool whatever remains.
- Identity: ipinfo.io (fallback ip-api.com) at start and hourly; only public IP, ASN, org, country, city and coordinates rounded to 2 decimals are kept.
- Self-update: every 6 h
GET /agent/latest; a different version with an asset forGOOS-GOARCHis downloaded todata_dir/update.tmp, sha256-verified,chmod 0755, renamed over the running binary, then the agent exits 0 and the supervisor restarts it. Each version is attempted at most once (data_dir/update.last).
Local endpoints
GET http://127.0.0.1:9381/healthz→{"ok":true,"probe_id":"…","version":"0.1.0","buffered":0,"spool_bytes":0,"last_flush":"…","last_config":"…","config_version":"…","targets":250,"clock_offset_ms":-14,"uptime_s":…,"capabilities":[…]}GET /metrics→ip_probe_measurements_total{kind},ip_probe_errors_total{code},ip_probe_buffered,ip_probe_spool_bytes,ip_probe_flush_failures_total,ip_probe_check_panics_total,ip_probe_clock_offset_ms,ip_probe_targets,ip_probe_uptime_seconds,ip_probe_rss_mb,ip_probe_goroutines,ip_probe_info.
Install
make build
sudo deploy/install.sh ca-qc-01 <64-hex-key> https://www.internetpressure.io/ingest/v1The installer is idempotent: it installs dist/ip-probe-<os>-<arch> to /usr/local/bin/ip-probe, writes
/etc/internetpressure/probe.yaml (0600; kept if id/key/url are unchanged), creates /var/lib/internetpressure
(0700) and (re)starts the service. Re-running upgrades the binary and restarts.
- macOS: LaunchDaemon
/Library/LaunchDaemons/io.internetpressure.probe.plist(KeepAlive,RunAtLoad,UserName=$IP_PROBE_USERor the invoking user, logs in/var/log/internetpressure-probe.log). Unprivileged ICMP and UDP traceroute work for any user. - Linux: system user
ip-probe,/etc/systemd/system/ip-probe.service(Restart=always,RestartSec=5),/etc/sysctl.d/60-ip-probe.confsetsnet.ipv4.ping_group_range = 0 2147483647so ICMP needs no capability.traceroutemust be installed (apt install traceroute).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
config fetch failed … http 401 |
Wrong key/probe_id, or clock skew > 300 s (fix NTP). The agent keeps retrying and runs from the cached config meanwhile. |
ping measurements have kind: "tcp" and error: "icmp_unavailable" |
Linux without net.ipv4.ping_group_range (installer sets it; otherwise sysctl -w net.ipv4.ping_group_range="0 2147483647"). |
| No traceroutes | No traceroute binary at /usr/sbin/traceroute (macOS) / /usr/bin/traceroute (Linux). Capability list in /healthz omits traceroute. |
interval below minimum, clamped |
Server asked for < 10 s; the agent enforces the 10 s floor (ethics rule). |
batch spooled … server unavailable |
Normal during an outage: ls /var/lib/internetpressure/spool; drained oldest-first when the server returns. |
local clock differs from server by more than 120 s |
Enable NTP; signatures still pass because timestamps are offset-corrected, but measurements are timestamped with the local clock. |
| Debug a target | ip-probe once --target host.example --url https://host.example/path |
| Where do logs go? | macOS /var/log/internetpressure-probe.log; Linux journalctl -u ip-probe -f. JSON lines, log_level: debug for per-batch details. |
Notes on the protocol implementation
- Every wire field name/type follows
docs/PROBE-PROTOCOL.md. Fields not relevant to akindare omitted (pointer +omitempty), which the protocol allows ("omitted or null");total_ms/rtt_msin traceroutes are explicitnullwhen unknown. ttfb_msis measured from the start of the check (before DNS), i.e. the conventional time-to-first-byte, sodns_ms + tcp_ms + tls_ms ≤ ttfb_ms ≤ total_msas in the protocol's example values.http_4xxis reported withok=true(connection + TLS fine, status < 500), per theokdefinition.- On the ICMP → TCP fallback the measurement is
kind:"tcp"and, when at least one connect succeeded,error:"icmp_unavailable"withok:true— an informational code so the server can tell a realtcpcheck from a degradedping. rss_mbis computed fromruntime.MemStats(Sys − HeapReleased), a close upper bound of the true RSS.