Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 47 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name> failed, trying next server` on stderr.

## Requirements

Runs on Linux, macOS, and Windows. CI builds and tests all three.
Expand Down Expand Up @@ -81,20 +120,17 @@ 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: <name> 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

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).
9 changes: 9 additions & 0 deletions crates/linkprobe-core/src/backends/iperf3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand All @@ -28,21 +33,25 @@ impl Iperf3Engine {
}
}

/// Path to the `iperf3` executable (default: `"iperf3"` on PATH).
pub fn with_binary(mut self, binary: impl Into<PathBuf>) -> 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<String>) -> Self {
let b = bandwidth.into();
self.bandwidth = if b.is_empty() { "10M".into() } else { b };
Expand Down
5 changes: 5 additions & 0 deletions crates/linkprobe-core/src/backends/librespeed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Error> {
let client = Client::builder()
.user_agent(concat!("linkprobe/", env!("CARGO_PKG_VERSION")))
Expand Down
12 changes: 11 additions & 1 deletion crates/linkprobe-core/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -69,11 +71,13 @@ fn join(base: &str, path: &str) -> Result<Url, Error> {
Ok(Url::parse(&base)?.join(path)?)
}

/// Parse a LibreSpeed server list JSON document into [`Server`] values.
pub fn parse_librespeed_servers(json: &str) -> Result<Vec<Server>, Error> {
let entries: Vec<LibreSpeedListEntry> = 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<Vec<Server>, Error> {
let text = client.get(list_url).send()?.error_for_status()?.text()?;
parse_librespeed_servers(&text)
Expand Down Expand Up @@ -142,6 +146,7 @@ pub fn parse_port_range(raw: &str) -> Result<Vec<u16>, Error> {
Ok(vec![port])
}

/// Parse an iperf3 public server list JSON document into [`Server`] values.
pub fn parse_iperf3_servers(json: &str) -> Result<Vec<Server>, Error> {
let entries: Vec<Iperf3ListEntry> = serde_json::from_str(json)?;
let mut servers = Vec::new();
Expand All @@ -164,11 +169,13 @@ pub fn parse_iperf3_servers(json: &str) -> Result<Vec<Server>, Error> {
Ok(servers)
}

/// Download and parse an iperf3 server list from `list_url`.
pub fn fetch_iperf3_servers(client: &Client, list_url: &str) -> Result<Vec<Server>, 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
Expand All @@ -186,6 +193,7 @@ pub fn ping_ms(client: &Client, server: &Server) -> Result<f64, Error> {
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 {
Expand All @@ -197,14 +205,15 @@ 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()
.next()
.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,
Expand All @@ -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<Server, Error> {
let key = id.to_string();
servers
Expand Down
13 changes: 13 additions & 0 deletions crates/linkprobe-core/src/error.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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),

Expand Down
10 changes: 9 additions & 1 deletion crates/linkprobe-core/src/export/prometheus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 47 additions & 2 deletions crates/linkprobe-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Measurement, Error>;
}
11 changes: 10 additions & 1 deletion crates/linkprobe-core/src/measurement.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
use serde::{Deserialize, Serialize};

/// Throughput in bits per second.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Throughput {
/// Bits per second.
pub bps: f64,
}

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<f64>,
/// Jitter in milliseconds.
pub jitter_ms: Option<f64>,
/// Download throughput when measured.
pub download: Option<Throughput>,
/// Upload throughput when measured.
pub upload: Option<Throughput>,
/// Fraction in [0.0, 1.0] when known.
/// Packet loss as a fraction in \[0.0, 1.0\] when known.
pub packet_loss: Option<f64>,
}
Loading