Skip to content

Repository files navigation

wire-probe

Zero-footprint L4 telemetry agent - TCP handshake RTT, sub-1 MB RAM, no runtime deps.

CI License: MIT Release

Why

ICMP ping is blocked by most firewalls and tells you nothing about the TCP stack. wire-probe measures the time for a full TCP three-way handshake - the same latency a real client sees - with no kernel module, no eBPF, no agent framework.

  • Server mode - io_uring accept/drop loop; accepts SYNs and immediately closes them. RSS stays ~460 KB regardless of connection rate because nothing is allocated per connection - but throughput is bounded by one accept in flight, so a rate high enough to fill the listen backlog delays handshakes rather than growing memory. Falls back to blocking accept() where io_uring is unavailable
  • Probe mode - blocking TcpStream::connect_timeout, with the RTT read from the kernel (TCP_INFO.tcpi_rtt) rather than timed in userspace; the --timeout flag is the only timing bound
  • Fire-and-forget export - UDP (Telegraf Influx Line Protocol) or Unix domain socket / stdout (Collectd PUTVAL); no buffering, no retries
  • Single static binary - musl-linked, ~490 KB stripped, zero glibc version dependency, works on Ubuntu, Fedora, Debian, RHEL, Alpine and any other Linux

Install

curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install.sh | sh

Or install a specific version:

curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install.sh | VERSION=0.1.9 sh

Pre-built tarballs for every platform are on the releases page.

Platform Tarball
Any Linux x86_64 (static) wire-probe-<ver>-linux-x86_64.tar.gz
Any Linux aarch64 (static) wire-probe-<ver>-linux-aarch64.tar.gz
Ubuntu 22.04 wire-probe-<ver>-ubuntu22.04-x86_64.tar.gz
Ubuntu 24.04 wire-probe-<ver>-ubuntu24.04-x86_64.tar.gz
Fedora 40 / 41 wire-probe-<ver>-fedora40-x86_64.tar.gz
Debian 12 wire-probe-<ver>-debian12-x86_64.tar.gz
Rocky Linux 9 / AlmaLinux 9 wire-probe-<ver>-rockylinux9-x86_64.tar.gz

The linux-x86_64 and linux-aarch64 tarballs are statically linked against musl libc and run on any Linux kernel ≥ 5.1 regardless of distro or glibc version.

Quick start

On the target host (DB node):

wire-probe --mode server --port 9999

On the observer host (PLT node):

# Export to Telegraf via UDP (Influx Line Protocol)
wire-probe --mode probe \
  --target db-host:9999 \
  --target-name mdb_primary \
  --az eu-west \
  --interval 1000ms \
  --export telegraf-udp://127.0.0.1:8094

# Export to Collectd via stdout (Exec plugin)
wire-probe --mode probe \
  --target db-host:9999 \
  --target-name mdb_primary \
  --interval 10s \
  --export collectd-exec

Modes

Server mode

Runs as a resident daemon on the target host. Uses an io_uring accept/drop loop - accepts each TCP SYN and immediately closes the socket without reading any data. No thread is spawned per connection.

wire-probe --mode server [--port <port>] [--bind <addr>]
Flag Default Description
--port 9999 TCP port to listen on
--bind 0.0.0.0 Bind address (use a private IP to restrict exposure)

Running under systemd

A ready-made unit ships in packaging/systemd/wire-probe-server.service:

curl -sSfL https://raw.githubusercontent.com/vorjdux/wire-probe/main/packaging/systemd/wire-probe-server.service \
  -o /etc/systemd/system/wire-probe-server.service

# adjust --port / --bind if needed
systemctl edit --full wire-probe-server.service

systemctl daemon-reload
systemctl enable --now wire-probe-server
systemctl status wire-probe-server

The unit assumes the binary is at /usr/local/bin/wire-probe (the default install.sh location when run as root) and restarts on failure with backoff.

It runs under DynamicUser=yes with a locked-down sandbox: no capabilities, a read-only filesystem, AF_INET/AF_INET6 only, and SystemCallFilter=@system-service (which nests @aio, where the io_uring syscalls live). systemd-analyze security rates it 1.3 OK, against 9.4 UNSAFE for the same unit unhardened. Binding a port below 1024 needs CAP_NET_BIND_SERVICE added back - see the commented lines in the unit.

Probe mode

Runs on the observer host. Measures the TCP handshake RTT to the target and exports the result on every interval. The target address is resolved once at startup - use an IP address if DNS reliability on your network is a concern.

wire-probe --mode probe --target <host:port> [options]
Flag Default Description
--target (required) host:port of the wire-probe server
--target-name derived from --target Label used in metric names
--az default Availability-zone tag (Telegraf only)
--interval 1000ms Time between probes (ms or s suffix); min 100ms, max 24h
--timeout 5000ms Connect timeout per probe; max 60s
--export collectd-exec Export destination (see below)

Export targets

Telegraf - Influx Line Protocol over UDP

--export telegraf-udp://<host>:<port>

Sends one UDP datagram per probe in Influx Line Protocol format:

tcp_latency,target=mdb_primary,az=eu-west rtt_ms=4.12,success=1i 1686561230000000000

A failed probe still emits a point, carrying success=0i and no rtt_ms:

tcp_latency,target=mdb_primary,az=eu-west success=0i 1686561231000000000

This is deliberate. Over fire-and-forget UDP a missing point is indistinguishable from a lost datagram, so alerting on absence cannot separate "target down" from "probe down". Alert on success instead, and note that rtt_ms is absent rather than zero on failure, so averages stay clean.

Telegraf configuration:

[[inputs.socket_listener]]
  service_address = "udp://127.0.0.1:8094"
  data_format     = "influx"

Collectd - PUTVAL

Exec plugin (stdout)

--export collectd-exec

Writes PUTVAL lines to stdout. Use with collectd's Exec plugin:

PUTVAL hostname/wire-probe-tcp/latency-mdb_primary interval=10 N:4.12

A failed probe sends N:U, collectd's "undefined" marker, rather than nothing:

PUTVAL hostname/wire-probe-tcp/latency-mdb_primary interval=10 N:U
<Plugin exec>
  Exec "nobody" "/usr/local/bin/wire-probe"
       "--mode"   "probe"
       "--target" "db-host:9999"
       "--target-name" "mdb_primary"
       "--interval" "10s"
       "--export" "collectd-exec"
</Plugin>

Unix domain socket

--export collectd-uds:///var/run/collectd-unixsock

Streams PUTVAL lines directly to collectd's UnixSock plugin.

Collectd Python plugin

A drop-in replacement for collectd's ping plugin - same value types (ping, ping_droprate, ping_stddev), no recompilation of collectd required.

Being a Python module, it is loaded via the python plugin - not with a bare <Plugin wire_probe> block (that will not load the module):

LoadPlugin python

<Plugin python>
  ModulePath "/usr/lib/collectd/wire_probe"
  Import "wire_probe"

  <Module wire_probe>
    Host "db-node-01"
    Host "db-node-02"
    Host "app-node-01"

    Port      9999
    Timeout   5.0
    PingCount 1
  </Module>
</Plugin>

Accuracy on a busy host. Both the plugin and the Rust probe read the handshake RTT from the kernel (TCP_INFO.tcpi_rtt, computed from the SYN → SYN-ACK exchange) rather than timing connect() in userspace. A stopwatch around connect() also times getting the process scheduled again once the handshake completes - inside CPython that means waiting for the GIL, and it lands in the value as latency the network never saw.

Measured on loopback, where the true RTT is ~0.005 ms:

p50 p99 max
userspace stopwatch, GIL contention 41 ms 461 ms 918 ms
kernel tcpi_rtt, same contention 0.036 ms 0.067 ms 0.15 ms
Rust probe, every core saturated 0.038 ms 0.064 ms 0.07 ms

The kernel value is used whenever it is available, with the wall clock as a fallback: non-Linux, a socket with no sample yet, or a handshake whose SYN was retransmitted. That last case matters - the kernel's smoothed RTT reflects the exchange that finally succeeded, so on partial packet loss it would report ~1 ms for a connect that really waited out a second-long RTO, hiding exactly what this probe exists to catch. tcpi_total_retrans is checked and the wall clock wins there. Verified with tc qdisc add dev lo root netem loss 25%: a clean loopback reported p50 0.035 ms, and under loss the same probe reported p50 1023 ms with a maximum of 2054 ms - one and two SYN retransmission timeouts, exactly the wait the kernel value would have hidden. Aggregate "min" exists for that fallback path, where the minimum of PingCount samples is the one least contaminated by scheduling.

The server side was never implicated: 500 concurrent handshakes against it measured p99 0.37 ms with no failures, no worse than a plain listener with a backlog of 128.

LoadPlugin python is only needed if the python plugin is not already loaded elsewhere in collectd.conf - drop that line if it is, to avoid a duplicate LoadPlugin warning at startup.

Where the data lands in InfluxDB: collectd's InfluxDB naming derives the measurement from the plugin name, not the value type - so probes appear in wire_probe_value (tag type in ping/ping_droprate/ping_stddev, type_instance = target host), not in ping_value. To fold the data into the existing ping_value measurement instead, set v.plugin = "ping" in wire_probe.py - at the cost of mixing L4 RTT with real ICMP ping.

Install:

curl -sSf https://raw.githubusercontent.com/vorjdux/wire-probe/main/install-plugin.sh | sudo sh

Or manually:

mkdir -p /usr/lib/collectd/wire_probe
cp plugin/collectd/wire_probe.py /usr/lib/collectd/wire_probe/
cp plugin/collectd/wire_probe.conf /etc/collectd/conf.d/
systemctl restart collectd   # collectd has no reload

See plugin/collectd/wire_probe.conf for the full configuration reference.

Build from source

Requires Rust 1.85+ (the crate is on edition = "2024") and Linux kernel

= 5.1 (for io_uring).

git clone https://github.com/vorjdux/wire-probe
cd wire-probe
cargo build --release

Static musl binary (runs anywhere):

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

Design

Concern Decision
Async runtime None - io_uring for the server accept loop, blocking threads for probes
Memory baseline ~460 KB RSS (server), ~440 KB RSS (probe), measured on x86_64/musl
Export allocations Zero - ryu/itoa format into pre-allocated stack buffers
Export reliability Fire-and-forget; no retry, no queue - if Telegraf/Collectd is down, the probe skips
Binary size ~490 KB musl static, ~380 KB glibc (fat LTO, panic = "abort", strip = true)

Behind the Design

wire-probe was built to measure pure L3/L4 Data Plane latency, completely isolated from SDN Control Plane throttling (which heavily skews ICMP ping on cloud providers like Azure) and L7 application bottlenecks. To achieve a zero-footprint observer effect, every architectural decision prioritized bypassing userland overhead.

1. Bypassing Async Runtimes for Direct Kernel Interfaces

Including a standard async runtime (like tokio) imposes an unacceptable baseline memory footprint (2–5 MB RSS) and scheduler overhead for a binary whose sole purpose is handling socket file descriptors.

  • Server mode (io_uring): The TCP accept loop is submitted to the Linux kernel's asynchronous submission/completion queues via io_uring. The daemon maintains an RSS around ~460 KB regardless of load because there are no per-connection allocations - accepted fds are closed immediately with a plain libc::close. Note: the current implementation uses a single outstanding accept (serial re-arm per connection); throughput is bounded by one submit_and_wait syscall per connection, which is sufficient for telemetry use but not for high-PPS scenarios.
  • Probe mode (native blocking): Uses TcpStream::connect_timeout on a blocking thread. The --timeout flag is the only timing bound - it maps directly to the OS-level connect timeout. The RTT itself is read from the kernel via TCP_INFO.tcpi_rtt, which measures the SYN → SYN-ACK exchange; Instant::now() around the connect call is the fallback for when the kernel has no sample, and the value used when the handshake needed a SYN retransmission, since the kernel's smoothed RTT would hide that wait. DNS is resolved at startup, outside the measurement, and every returned address is kept: after five consecutive failures the probe re-resolves and moves to the next one, so an unreachable AAAA in front of a working A recovers instead of pinning the process forever.

2. Zero-Allocation Export Path and Binary Density

To guarantee the export hot path runs within the CPU's L1/L2 caches and avoids memory fragmentation during long-running execution, the binary is extremely dense.

  • Compiled statically via musl-libc with fat LTO and panic = "abort". Measured on x86_64 with rustc 1.96: the static musl binary is ~490 KB stripped (the glibc build is ~380 KB), with ~460 KB RSS for the server and ~440 KB for the probe.
  • Heap allocations are eliminated on the export hot path: the metric prefix is built once at construction, the send buffer is reused via clear(), and ryu/itoa write directly into stack-allocated buffers with no format! or String intermediary. The buffer is sized from the prefix length plus fixed headroom for the value and timestamp, so long hostnames or tags do not force a reallocation on the first send.

3. Fire-and-Forget Export and Backpressure Offloading

An observability probe must not block, and must not be brought down, because the downstream telemetry pipeline degraded. How completely that holds depends on the exporter, and only the UDP path is fire-and-forget in the strict sense:

Export path Can a stalled consumer block the probe?
telegraf-udp:// No. send() on a UDP socket never blocks on the receiver; the kernel tail-drops. If Telegraf is not listening, the ICMP port-unreachable surfaces as ECONNREFUSED on the next send, which is logged and skipped.
collectd-uds:// Bounded. A stream socket blocks once the kernel buffer fills, so the write timeout is capped at 1s; the send then fails and the probe continues.
collectd-exec Yes, in principle. stdout is a pipe owned by collectd; if collectd stops reading and the pipe fills, the write blocks. Not capped, because a timeout cannot be set on a pipe the way it can on a socket.

collectd-uds:// also requires collectd to be listening at startup: the connect is made once, and the probe exits if it fails.

Note also that the release profile sets panic = "abort", so a panic terminates the process rather than unwinding - the guarantee is about backpressure, not that the process is panic-proof. Restart supervision is the systemd unit's job.

  • By forcing metric injection via UDP datagrams (Telegraf/Influx) or Unix domain sockets (Collectd), wire-probe structurally outsources backpressure handling to the Linux kernel.
  • If the destination TSDB stalls or the Telegraf process hangs, the kernel applies a silent tail-drop at the receive buffer. This isolates the probe, shielding it from file descriptor exhaustion or OOM kills.

4. Pragmatic Collectd Integration

Collectd's Exec plugin runs a child process once and reads its stdout in a long-lived loop - it does not re-fork per interval. The real cost it imposes is an out-of-process boundary: every read cycle crosses a pipe, a process boundary, and a shell.

  • wire_probe.py is a full Python reimplementation of the probe logic (not a wrapper around the Rust binary). It registers directly with collectd's C runtime via the Python plugin API (register_read callback), running in-process with no child process at all. This eliminates the pipe/process boundary entirely. The trade-off: the Rust binary's musl, zero-alloc, and io_uring properties do not apply on this path - you get CPython doing blocking socket.create_connection calls. For collectd environments the in-process scheduling and drop-in ping/ping_droprate/ping_stddev metric names make it the right integration point.

License

MIT - see LICENSE.

About

L4 Telemetry Agent (Zero-Footprint)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages