From 4188ab6d0499b24eba3d76fe950693c31b0f61fa Mon Sep 17 00:00:00 2001 From: Robert Mongold Date: Tue, 25 Aug 2026 20:10:24 -0400 Subject: [PATCH] Document linkprobe product contract and linkprobe-core API Add README positioning (what linkprobe is / is not), CLI modes, and server selection tables. Expand linkprobe-core crate and type rustdoc so the library contract is clear before crates.io packaging. --- CHANGELOG.md | 2 + README.md | 58 +++++++++++++++---- crates/linkprobe-core/src/backends/iperf3.rs | 9 +++ .../linkprobe-core/src/backends/librespeed.rs | 5 ++ crates/linkprobe-core/src/discovery.rs | 12 +++- crates/linkprobe-core/src/error.rs | 13 +++++ .../linkprobe-core/src/export/prometheus.rs | 10 +++- crates/linkprobe-core/src/lib.rs | 49 +++++++++++++++- crates/linkprobe-core/src/measurement.rs | 11 +++- crates/linkprobe-core/src/result.rs | 3 + crates/linkprobe-core/src/server.rs | 17 +++++- 11 files changed, 171 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 720cb64..b410ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- README positioning (what linkprobe is / is not), CLI modes, and server selection tables. +- `linkprobe-core` crate-level rustdoc, type docs, and a compile-checked library example. - GitHub Actions release workflow: attach Linux/macOS/Windows binaries to an existing GitHub Release. ## [0.2.0] - 2026-08-18 diff --git a/README.md b/README.md index 0ca83c9..2d96ddc 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,55 @@ JSON, MQTT, and Prometheus text exporters). Not affiliated with Ookla or speedtest.net. -## Status +## What linkprobe is -LibreSpeed and iperf3 backend work via the `linkprobe` CLI. +- **Link measurement** over LibreSpeed-compatible HTTPS or the system `iperf3` binary. +- **Two surfaces:** the [`linkprobe`](crates/linkprobe) CLI for ops and homelab use, and the + [`linkprobe-core`](crates/linkprobe-core) library to embed the same engines in your own apps. +- **Outputs:** human-readable text, JSON, OpenMetrics (file, stdout, or HTTP scrape), and + optional MQTT publish. -- LibreSpeed: measure a URL, pick from the public list, or auto-select by ping. +## What linkprobe is not + +- **Not Ookla or speedtest.net** — no affiliation and no proprietary speedtest SDK. +- **Not a bundled speedtest engine** — measurements run against LibreSpeed-compatible servers + you choose or public lists, or against an `iperf3` endpoint you control. +- **Not [probe-rs](https://crates.io/crates/probe-rs)** — linkprobe measures network links; + probe-rs is an embedded debugging toolkit. + +## Features + +- LibreSpeed: measure a URL, pick from the public list, or auto-select by lowest ping. `--server-id` and auto-pick try up to two more hosts if the first still fails after HTTP retries. - iperf3: requires `iperf3` on `PATH`; optional `--list` / `--server-id` from the public server JSON. - After a run: human or `--json` stdout, optional MQTT publish, optional OpenMetrics file/stdout or HTTP scrape via `--listen`. +## CLI modes + +| Mode | Flags | Behavior | +|------|-------|----------| +| One-shot | default (no `--listen`) | Run one probe; print human text or `--json` to stdout | +| Prometheus text | `--prometheus-text [PATH]` | Write OpenMetrics text; `-` prints metrics to stdout instead of human output | +| Scrape daemon | `--listen ADDR` + `--interval SECS` | Background HTTP server on GET `/metrics`; reprobe on an interval | +| MQTT push | `--mqtt-url URL` | After each probe, publish the JSON `RunResult` (default topic: `linkprobe/result`) | + +Modes can be combined where it makes sense (for example one-shot probe plus `--prometheus-text` +and `--mqtt-url`). + +## Server selection + +| Input | Behavior | +|-------|----------| +| `--server URL` or `--server HOST` | Single explicit LibreSpeed base URL or iperf3 host only (no list failover) | +| `--server-id N` | Pick entry `N` from `--list`; LibreSpeed auto-pick / `--server-id` may try up to two more list servers after retries | +| `--server` omitted (LibreSpeed default backend) | Fetch the public list, rank by ping, probe the fastest | +| `--list` | Print server ids and names, then exit | + +LibreSpeed probes retry each HTTP phase up to three times. When using auto-pick or `--server-id`, +linkprobe may rotate through up to two additional list servers (by ping order) if the preferred +host still fails. On rotation you will see `linkprobe: failed, trying next server` on stderr. + ## Requirements Runs on Linux, macOS, and Windows. CI builds and tests all three. @@ -81,15 +120,12 @@ Optional: `--servers-url` for a custom server list (LibreSpeed or iperf3 JSON, d MQTT extras: `--mqtt-username`, `--mqtt-password` -Public LibreSpeed hosts can drop connections: linkprobe retries each phase up to three times, -then auto-pick and `--server-id` try up to two more list servers (by ping). Explicit `--server` -URLs are single-host only. On rotation you will see `linkprobe: failed, trying next server` -on stderr. - ## Crates -- `linkprobe-core` - measurement types, LibreSpeed/iperf3 backends, discovery, OpenMetrics -- `linkprobe` - CLI, MQTT, scrape HTTP +- `linkprobe-core` — measurement types, LibreSpeed/iperf3 backends, discovery, OpenMetrics formatting +- `linkprobe` — CLI, MQTT client, Prometheus scrape HTTP server + +Library API docs: `cargo doc -p linkprobe-core --open` (or docs.rs after publish). ## License @@ -97,4 +133,4 @@ MIT OR Apache-2.0 ## History -Inspired by [speedtest-rs](https://github.com/nelsonjchen/speedtest-rs); see `NOTICE`. +Inspired by [speedtest-rs](https://github.com/nelsonjchen/speedtest-rs); see [NOTICE](NOTICE). diff --git a/crates/linkprobe-core/src/backends/iperf3.rs b/crates/linkprobe-core/src/backends/iperf3.rs index 45be589..c340ca7 100644 --- a/crates/linkprobe-core/src/backends/iperf3.rs +++ b/crates/linkprobe-core/src/backends/iperf3.rs @@ -10,6 +10,10 @@ use crate::{Error, MeasurementEngine}; const DEFAULT_DURATION_SECS: u64 = 5; const DEFAULT_PORT: u16 = 5201; +/// iperf3 measurement by spawning the system `iperf3` binary with `-J` JSON output. +/// +/// Requires `iperf3` on `PATH` unless overridden with [`with_binary`](Self::with_binary). +/// UDP mode ([`with_udp`](Self::with_udp)) fills jitter and packet loss; TCP mode does not. #[derive(Debug, Clone)] pub struct Iperf3Engine { binary: PathBuf, @@ -19,6 +23,7 @@ pub struct Iperf3Engine { } impl Iperf3Engine { + /// Default engine: `iperf3` on PATH, 5 s per direction, TCP. pub fn new() -> Self { Self { binary: PathBuf::from("iperf3"), @@ -28,21 +33,25 @@ impl Iperf3Engine { } } + /// Path to the `iperf3` executable (default: `"iperf3"` on PATH). pub fn with_binary(mut self, binary: impl Into) -> Self { self.binary = binary.into(); self } + /// Test duration in seconds for each iperf3 direction (minimum 1). pub fn with_duration_secs(mut self, secs: u64) -> Self { self.duration_secs = secs.max(1); self } + /// Use UDP (`-u`) instead of TCP; enables packet-loss reporting when the server supports it. pub fn with_udp(mut self, udp: bool) -> Self { self.udp = udp; self } + /// UDP target bandwidth passed to iperf3 `-b` (default `"10M"`). pub fn with_bandwidth(mut self, bandwidth: impl Into) -> Self { let b = bandwidth.into(); self.bandwidth = if b.is_empty() { "10M".into() } else { b }; diff --git a/crates/linkprobe-core/src/backends/librespeed.rs b/crates/linkprobe-core/src/backends/librespeed.rs index c81319d..5dd62bf 100644 --- a/crates/linkprobe-core/src/backends/librespeed.rs +++ b/crates/linkprobe-core/src/backends/librespeed.rs @@ -12,12 +12,17 @@ const DOWNLOAD_CHUNK_SIZE: u32 = 4; // 4 MiB from garbage.php const UPLOAD_BYTES: usize = 2 * 1024 * 1024; const HTTP_ATTEMPTS: usize = 3; +/// LibreSpeed-compatible measurement over blocking HTTPS. +/// +/// Each HTTP phase (ping, download, upload) is retried up to three times on timeout, +/// connection failure, or truncated bodies. #[derive(Debug, Clone)] pub struct LibreSpeedEngine { client: Client, } impl LibreSpeedEngine { + /// Build an engine with a default reqwest client (60 s timeout, linkprobe user agent). pub fn new() -> Result { let client = Client::builder() .user_agent(concat!("linkprobe/", env!("CARGO_PKG_VERSION"))) diff --git a/crates/linkprobe-core/src/discovery.rs b/crates/linkprobe-core/src/discovery.rs index f266d49..901b750 100644 --- a/crates/linkprobe-core/src/discovery.rs +++ b/crates/linkprobe-core/src/discovery.rs @@ -13,6 +13,8 @@ pub const DEFAULT_LIBRESPEED_SERVERS_URL: &str = pub const DEFAULT_IPERF3_SERVERS_URL: &str = "https://export.iperf3serverlist.net/listed_iperf3_servers.json"; +/// How many additional list servers the CLI may try after the preferred host fails +/// (auto-pick and `--server-id` only; explicit `--server` URLs do not rotate). pub const FAILOVER_EXTRA: usize = 2; #[derive(Debug, Clone, Deserialize)] @@ -69,11 +71,13 @@ fn join(base: &str, path: &str) -> Result { Ok(Url::parse(&base)?.join(path)?) } +/// Parse a LibreSpeed server list JSON document into [`Server`] values. pub fn parse_librespeed_servers(json: &str) -> Result, Error> { let entries: Vec = serde_json::from_str(json)?; Ok(entries.into_iter().map(|e| e.into_server()).collect()) } +/// Download and parse a LibreSpeed server list from `list_url`. pub fn fetch_librespeed_servers(client: &Client, list_url: &str) -> Result, Error> { let text = client.get(list_url).send()?.error_for_status()?.text()?; parse_librespeed_servers(&text) @@ -142,6 +146,7 @@ pub fn parse_port_range(raw: &str) -> Result, Error> { Ok(vec![port]) } +/// Parse an iperf3 public server list JSON document into [`Server`] values. pub fn parse_iperf3_servers(json: &str) -> Result, Error> { let entries: Vec = serde_json::from_str(json)?; let mut servers = Vec::new(); @@ -164,11 +169,13 @@ pub fn parse_iperf3_servers(json: &str) -> Result, Error> { Ok(servers) } +/// Download and parse an iperf3 server list from `list_url`. pub fn fetch_iperf3_servers(client: &Client, list_url: &str) -> Result, Error> { let text = client.get(list_url).send()?.error_for_status()?.text()?; parse_iperf3_servers(&text) } +/// Pick the default iperf3 list URL when the CLI still has the LibreSpeed default configured. pub fn servers_list_url(backend_is_iperf3: bool, servers_url: &str) -> &str { if backend_is_iperf3 && servers_url == DEFAULT_LIBRESPEED_SERVERS_URL { DEFAULT_IPERF3_SERVERS_URL @@ -186,6 +193,7 @@ pub fn ping_ms(client: &Client, server: &Server) -> Result { Ok(start.elapsed().as_secs_f64() * 1000.0) } +/// Ping servers and return those that responded, sorted by ascending latency (ms). pub fn rank_by_latency(client: &Client, servers: &[Server]) -> Vec<(Server, f64)> { let mut ranked = Vec::new(); for s in servers { @@ -197,6 +205,7 @@ pub fn rank_by_latency(client: &Client, servers: &[Server]) -> Vec<(Server, f64) ranked } +/// Return the server with the lowest ping latency, or an error if none responded. pub fn pick_lowest_latency(client: &Client, servers: &[Server]) -> Result<(Server, f64), Error> { rank_by_latency(client, servers) .into_iter() @@ -204,7 +213,7 @@ pub fn pick_lowest_latency(client: &Client, servers: &[Server]) -> Result<(Serve .ok_or_else(|| Error::Message("no LibreSpeed servers responded to ping".into())) } -/// `preferred` first, then up to `extra` others in ping order (excluding preferred). +/// Build a probe order: `preferred` first, then up to `extra` other servers by ping rank. pub fn failover_candidates( ranked: &[(Server, f64)], preferred: &Server, @@ -223,6 +232,7 @@ pub fn failover_candidates( out } +/// Look up a server by numeric list id (string match on [`Server::id`]). pub fn server_by_id(servers: &[Server], id: u64) -> Result { let key = id.to_string(); servers diff --git a/crates/linkprobe-core/src/error.rs b/crates/linkprobe-core/src/error.rs index 205c767..c7371aa 100644 --- a/crates/linkprobe-core/src/error.rs +++ b/crates/linkprobe-core/src/error.rs @@ -1,13 +1,25 @@ use thiserror::Error; +/// Failure from discovery, measurement, or export helpers. +/// +/// Match on this instead of string-matching `Display` output: +/// +/// - [`Error::Iperf3Missing`](Self::Iperf3Missing) — `iperf3` binary not found when using +/// [`Iperf3Engine`](crate::backends::Iperf3Engine) +/// - [`Error::Probe`](Self::Probe) — a named measurement phase failed (for example `"download"`); +/// the `source` chain holds the underlying error +/// - [`Error::Http`](Self::Http), [`Error::Io`](Self::Io), [`Error::Json`](Self::Json) — +/// transparent wrappers for reqwest, I/O, and JSON errors #[derive(Debug, Error)] pub enum Error { + /// General failure with a message (unknown server id, invalid CLI combination in the binary crate, etc.). #[error("{0}")] Message(String), #[error("not implemented")] NotImplemented, + /// A measurement phase failed; inspect `phase` and `source`. #[error("{phase} failed: {source}")] Probe { phase: &'static str, @@ -18,6 +30,7 @@ pub enum Error { #[error("iperf3 not found on PATH (install iperf3 to use --backend iperf3)")] Iperf3Missing, + /// MQTT publish failure (CLI crate). #[error("mqtt: {0}")] Mqtt(String), diff --git a/crates/linkprobe-core/src/export/prometheus.rs b/crates/linkprobe-core/src/export/prometheus.rs index ee81070..97219cb 100644 --- a/crates/linkprobe-core/src/export/prometheus.rs +++ b/crates/linkprobe-core/src/export/prometheus.rs @@ -6,7 +6,12 @@ fn escape_label(s: &str) -> String { .replace('\n', "\\n") } -/// OpenMetrics /Prometheus text exposition for a single run. +/// OpenMetrics / Prometheus text exposition for a successful [`RunResult`]. +/// +/// Emits stable gauge names: `linkprobe_ok`, `linkprobe_latency_milliseconds`, +/// `linkprobe_jitter_milliseconds`, `linkprobe_download_bits_per_second`, +/// `linkprobe_upload_bits_per_second`, and `linkprobe_packet_loss`. Only metrics present on +/// the result are written. pub fn format_openmetrics(result: &RunResult) -> String { let backend = escape_label(&result.backend); let server = escape_label(&result.server.name); @@ -55,6 +60,9 @@ pub fn format_openmetrics(result: &RunResult) -> String { out } +/// OpenMetrics text for a failed probe (for scrape daemons that must always expose metrics). +/// +/// Sets `linkprobe_ok` to `0` and adds a `linkprobe_last_error` series with the error message. pub fn format_openmetrics_failed(backend: &str, server: &str, err: &str) -> String { let backend = escape_label(backend); let server = escape_label(server); diff --git a/crates/linkprobe-core/src/lib.rs b/crates/linkprobe-core/src/lib.rs index 24412d7..ea48c20 100644 --- a/crates/linkprobe-core/src/lib.rs +++ b/crates/linkprobe-core/src/lib.rs @@ -1,4 +1,45 @@ -//! Protocol-agnostic link measurement types and engine trait. +//! Protocol-agnostic network link measurement for Rust. +//! +//! `linkprobe-core` provides shared types, server discovery, measurement backends, and +//! OpenMetrics formatting. The [`linkprobe`](https://github.com/rtmongold/linkprobe) CLI +//! crate adds argument parsing, MQTT publish, and an HTTP scrape endpoint on top of this +//! library. +//! +//! Use this crate when you want to embed link measurement in an agent, dashboard, or test +//! harness. Use the CLI when you want a ready-made probe with JSON, Prometheus, and MQTT +//! exporters. +//! +//! # Backends +//! +//! All engines implement [`MeasurementEngine`] and run **synchronously** (blocking HTTP or a +//! subprocess). Pick the backend that matches your endpoint: +//! +//! | Backend | Type | Requires | Typical fields | +//! | --- | --- | --- | --- | +//! | LibreSpeed | [`LibreSpeedEngine`](backends::LibreSpeedEngine) | Outbound HTTPS | latency, jitter, download, upload | +//! | iperf3 | [`Iperf3Engine`](backends::Iperf3Engine) | `iperf3` on `PATH` | latency, jitter, download, upload; UDP adds packet loss | +//! +//! Optional fields on [`Measurement`] mean the backend did not report that metric for the run +//! (for example TCP iperf3 has no packet loss). +//! +//! # Example +//! +//! ```no_run +//! use linkprobe_core::backends::LibreSpeedEngine; +//! use linkprobe_core::{MeasurementEngine, Server}; +//! +//! let server = Server::librespeed("https://example-librespeed/"); +//! let engine = LibreSpeedEngine::new()?; +//! let measurement = engine.measure(&server)?; +//! +//! if let Some(ms) = measurement.latency_ms { +//! println!("latency: {ms:.1} ms"); +//! } +//! # Ok::<(), linkprobe_core::Error>(()) +//! ``` +//! +//! Discovery helpers such as [`fetch_librespeed_servers`] and [`rank_by_latency`] need a +//! network connection. See [`FAILOVER_EXTRA`] for list rotation behavior used by the CLI. mod discovery; mod error; @@ -20,7 +61,11 @@ pub use measurement::{Measurement, Throughput}; pub use result::RunResult; pub use server::Server; -/// Runs latency / download / upload (and optional loss) against a server. +/// Runs latency, download, upload, and optional packet-loss measurement against a [`Server`]. +/// +/// A single call performs the full probe for that backend (ping/jitter plus throughput tests). +/// Implement this trait to add new measurement protocols alongside +/// [`LibreSpeedEngine`](backends::LibreSpeedEngine) and [`Iperf3Engine`](backends::Iperf3Engine). pub trait MeasurementEngine { fn measure(&self, server: &Server) -> Result; } diff --git a/crates/linkprobe-core/src/measurement.rs b/crates/linkprobe-core/src/measurement.rs index c38713a..1d271a3 100644 --- a/crates/linkprobe-core/src/measurement.rs +++ b/crates/linkprobe-core/src/measurement.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +/// Throughput in bits per second. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Throughput { /// Bits per second. @@ -7,23 +8,31 @@ pub struct Throughput { } impl Throughput { + /// Construct from a raw bit rate. pub fn from_bps(bps: f64) -> Self { Self { bps } } + /// Convert to megabits per second (decimal `1_000_000` bps per Mbps). pub fn mbps(self) -> f64 { self.bps / 1_000_000.0 } } +/// Result of one link measurement against a server. +/// +/// Fields are optional when the backend does not report them (for example packet loss is only +/// filled for iperf3 UDP runs). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Measurement { /// Round-trip latency in milliseconds. pub latency_ms: Option, /// Jitter in milliseconds. pub jitter_ms: Option, + /// Download throughput when measured. pub download: Option, + /// Upload throughput when measured. pub upload: Option, - /// Fraction in [0.0, 1.0] when known. + /// Packet loss as a fraction in \[0.0, 1.0\] when known. pub packet_loss: Option, } diff --git a/crates/linkprobe-core/src/result.rs b/crates/linkprobe-core/src/result.rs index 72de4e1..dc425d8 100644 --- a/crates/linkprobe-core/src/result.rs +++ b/crates/linkprobe-core/src/result.rs @@ -3,14 +3,17 @@ use serde::{Deserialize, Serialize}; use crate::measurement::Measurement; use crate::server::Server; +/// Outcome of one completed probe, suitable for JSON serialization or OpenMetrics export. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunResult { + /// Backend name (for example `"librespeed"` or `"iperf3"`). pub backend: String, pub server: Server, pub measurement: Measurement, } impl RunResult { + /// Construct a result from a backend label, server, and measurement. pub fn new(backend: impl Into, server: Server, measurement: Measurement) -> Self { Self { backend: backend.into(), diff --git a/crates/linkprobe-core/src/server.rs b/crates/linkprobe-core/src/server.rs index a5fac79..66fa18b 100644 --- a/crates/linkprobe-core/src/server.rs +++ b/crates/linkprobe-core/src/server.rs @@ -1,14 +1,25 @@ use serde::{Deserialize, Serialize}; +/// Target server for a link measurement. +/// +/// For LibreSpeed, `base_url` is the instance root (for example `https://host/backend/`). +/// Path fields (`dl_path`, `ul_path`, `ping_path`) are joined relative to that base. +/// +/// For iperf3, `base_url` holds the host name or address and [`port`](Self::port) holds the +/// port (default 5201 when `None`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Server { + /// List id or host-derived identifier. pub id: String, + /// Human-readable label (shown in CLI output and Prometheus labels). pub name: String, - /// Base URL of the LibreSpeed (or compatible) instance, e.g. `https://example/`. + /// LibreSpeed base URL or iperf3 host, depending on the backend. pub base_url: String, + /// Country code from a server list, when present. pub country: Option, + /// Sponsor or provider name from a server list, when present. pub sponsor: Option, - /// iperf3 port (LibreSpeed leaves this `None`). + /// iperf3 port (`None` for LibreSpeed servers). pub port: Option, /// Relative download path (default LibreSpeed: `backend/garbage.php`). #[serde(default = "default_dl_path")] @@ -34,6 +45,7 @@ fn default_ping_path() -> String { } impl Server { + /// Build a LibreSpeed target with default relative paths. pub fn librespeed(base_url: impl Into) -> Self { let base_url = base_url.into(); Self { @@ -49,6 +61,7 @@ impl Server { } } + /// Build an iperf3 target (`base_url` is the host; `port` is stored on the server). pub fn iperf3(host: impl Into, port: u16) -> Self { let host = host.into(); Self {