icmpengine

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 23 Imported by: 0

README

ICMPEngine

ICMPEngine is a small, embeddable library for sending non-privileged ICMP echo requests and receiving replies concurrently, without blocking on per-packet timeouts.

Key features:

  • One IPv4 socket and one IPv6 socket; matches replies to requests across many destinations concurrently.
  • Does not wait for a packet's timeout before sending the next — outstanding pings are tracked centrally.
  • A single expiry timer tracks the soonest-expiring outstanding ping using container/heap (a typed priority queue). Timeouts are per-ping, so one engine can mix a 10ms LAN host and an hours-away link (see PingTimeout).
  • Built to embed: context.Context cancellation, functional-options construction, errors returned instead of log.Fatal, and standard-library log/slog logging (pass nil for none — no logging dependency).
  • Leverages golang.org/x/net/icmp and IPPROTO_ICMP NonPrivilegedPing sockets (lwn.net/Articles/422330).
  • Uses the standard library net/netip IP type.
  • Configurable ICMP payload size and DSCP marking, for both IPv4 and IPv6 (see Packet size and DSCP).
go get github.com/randomizedcoder/icmpengine

Non-privileged ICMP on Linux requires the ping group range to include your gid:

sudo sysctl -w net.ipv4.ping_group_range="0 2147483647"

Quickstart

Ping a single host (see ./example/simple):

package main

import (
	"context"
	"fmt"
	"net/netip"
	"time"

	"github.com/randomizedcoder/icmpengine"
)

func main() {
	eng, err := icmpengine.New(
		icmpengine.WithTimeout(500 * time.Millisecond),
	)
	if err != nil {
		panic(err)
	}

	ctx := context.Background()
	if err := eng.Start(ctx); err != nil {
		panic(err)
	}
	defer eng.Close()

	res, err := eng.Ping(ctx, netip.MustParseAddr("8.8.8.8"), 10, 100*time.Millisecond, icmpengine.SortRTTs())
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s: success=%d/%d min=%s mean=%s max=%s\n",
		res.IP, res.Successes, res.Count, res.Min, res.Mean, res.Max)
}

Ping many hosts concurrently with a bounded worker pool (see ./example/concurrent):

targets := []icmpengine.Target{
	{Addr: netip.MustParseAddr("8.8.8.8"), Count: 20, Interval: 50 * time.Millisecond},
	{Addr: netip.MustParseAddr("1.1.1.1"), Count: 20, Interval: 50 * time.Millisecond},
}
results, err := eng.PingAll(ctx, 4 /* workers */, targets) // results aligned to targets

Runnable examples and a fuller CLI:

go run ./example/simple     -dest 8.8.8.8 -count 5
go run ./example/concurrent -dest 8.8.8.8,1.1.1.1 -workers 4
go run ./cmd/icmpengine     -dest 127.0.0.1,::1 -count 10
Logging

The engine logs via log/slog. Pass icmpengine.WithLogger(l) to supply a *slog.Logger, or omit it (or pass nil) to disable logging entirely — there is no logging dependency to pull in.

Per-ping timeouts

WithTimeout sets the engine's default, but each Ping (and each PingAll Target) can override it with PingTimeout, so different destinations can wait different amounts of time — a 10ms LAN host and an hours-away interplanetary link in the same engine:

res, _ := eng.Ping(ctx, lan,  5, time.Second, icmpengine.PingTimeout(10*time.Millisecond))
res, _ := eng.Ping(ctx, mars, 5, time.Minute, icmpengine.PingTimeout(3*time.Hour))

The timeout machinery is covered by deterministic testing/synctest tests that exercise the full range (LAN microseconds → Mars-rover hours) in fake time, so a 3-hour timeout test runs in microseconds.

Packet size, DSCP, TTL, source address and Don't-Fragment

Payload size is a per-ping option. PayloadSize(n) appends n data bytes after the 8-byte ICMP header — the same semantics as ping -s n (so PayloadSize(56) yields a 64-byte ICMP message; the kernel adds the IP header). It defaults to 0 (a bare echo request) and accepts 0..65500. It works identically for IPv4 and IPv6:

res, _ := eng.Ping(ctx, addr, 5, time.Second, icmpengine.PayloadSize(56))

DSCP is an engine-level option. WithDSCP(v) marks every echo request with the 6-bit DiffServ code point v (0..63), written into the IPv4 ToS / IPv6 Traffic Class byte as v<<2 (the low two ECN bits stay zero). It is applied once, at Start, to both the IPv4 and IPv6 socket. It is engine-wide rather than per-ping because the engine shares one socket per family across all concurrent pings, and IPv4 only exposes ToS as a persistent socket option:

eng, _ := icmpengine.New(icmpengine.WithDSCP(46)) // 46 = EF (Expedited Forwarding)

TTL / hop limit is also engine-level. WithTTL(n) sets the outgoing IPv4 TTL (and the equivalent IPv6 hop limit) for every echo request, applied once at Start to both sockets via SetTTL / SetHopLimit. It defaults to 64 (the Linux ping / kernel default); a value of 0 keeps the kernel default without setting the option, and the accepted range is 0..255:

eng, _ := icmpengine.New(icmpengine.WithTTL(1)) // e.g. TTL 1 to only reach the first hop

Source address is engine-level. WithSource(addr) binds the sockets so echo requests leave from a specific local address (useful on multi-homed hosts). The address applies to its own family only (an IPv4 source binds the IPv4 socket and leaves IPv6 on the wildcard, and vice versa); binding a non-local address makes Start fail:

eng, _ := icmpengine.New(icmpengine.WithSource(netip.MustParseAddr("10.0.0.5")))

Don't-Fragment is engine-level and Linux-only. WithDontFragment(true) sets the IPv4 DF bit (and the IPv6 equivalent) by enabling path-MTU discovery on the socket (IP_MTU_DISCOVER = IP_PMTUDISC_DO) — the same trick ping -M do uses, so no raw socket / CAP_NET_RAW is required. Combined with PayloadSize it probes the path MTU. On non-Linux platforms Start returns ErrDontFragmentUnsupported:

eng, _ := icmpengine.New(icmpengine.WithDontFragment(true))
res, _ := eng.Ping(ctx, addr, 1, 0, icmpengine.PayloadSize(1472)) // fails if the path MTU is smaller

The CLI exposes these as -size (bytes), -dscp (0–63), -ttl (1–255), -source (bind address) and -df (Don't-Fragment):

icmpengine -dest 8.8.8.8,2001:4860:4860::8888 -count 5 -size 56 -dscp 46 -ttl 64 -df
Expiry backend

Outstanding pings are tracked in a swappable "expiry tracker" (a priority queue with arbitrary removal). Six backends are built in and selectable at construction, all validated by the same correctness suite:

  • icmpengine.BackendDaryHeap — 8-ary array heap (default; fastest on the realistic workload, O(1) peek, no external dependency).
  • icmpengine.BackendHeapcontainer/heap binary min-heap (stdlib, O(1) peek, no external dependency).
  • icmpengine.BackendPairing — pairing heap.
  • icmpengine.BackendBTreegithub.com/google/btree.
  • icmpengine.BackendRadix — fixed-base radix trie.
  • icmpengine.BackendTimingWheel — hierarchical timing wheel + btree overflow.
eng, _ := icmpengine.New(icmpengine.WithExpiryBackend(icmpengine.BackendHeap)) // opt into the stdlib heap

The CLI exposes this as -backend heap|dary|pairing|btree|radix|wheel. The 8-ary heap is the default — benchmarking across uniform and mixed (heterogeneous per-ping timeouts µs…hours) workloads plus the engine-level fleet shows it consistently fastest, at the same allocations as the binary heap (just a shallower, more cache-friendly array). The flat array heaps beat the pointer/bucket structures because at these sizes cache behavior and constant factors decide it, not asymptotics.

See docs/backends.md for a per-backend guide (what each is, pros/cons, when to pick it) and docs/benchmarking.md for the full methodology, result tables, and the fan-out sweep.

xtcp diagram

Nix

This repo ships a Nix flake (thin flake.nix orchestrator + modular nix/).

nix develop                     # dev shell (Go, golangci-lint, gopls, delve, ...)
nix build .#icmpengine          # main binary (default variant)
nix build .#icmpengine-debug    # keeps symbols + DWARF
nix build .#icmpengine-stripped # smallest
nix run   .#default -- --dest 127.0.0.1,::1 --count 5

nix flake check                 # gofmt + nix-fmt + go-vet + golangci (Tier 0+1) + gosec + version smoke + race

Static analysis is tiered (like the reference xtcp2 flake):

lint-quick                      # Tier 0 golangci (~30s) — dev shell helper
lint                            # Tier 1 golangci (~2min, CI-gating)
lint-comprehensive              # Tier 2 golangci (~10min, non-gating)
nix build .#golangci-lint-comprehensive   # Tier 2 as a package
nix build .#quality-report && cat result/quality-report.md   # every tool, one report
nix run   .#quality-report      # print the aggregated report

nix flake check gates Tier 0, Tier 1, gosec, gofmt, nix-fmt, go-vet, the version smoke and the race detector. Tier 2 (complexity/duplication/naming) is surfaced for awareness via the quality-report and the comprehensive package, but does not gate CI.

OCI image (scratch-based, single binary):

nix build .#oci-icmpengine && ./result | docker load
docker run --rm icmpengine:latest --version
# runtime ping needs NET_RAW or a permissive ping_group_range:
docker run --rm --sysctl net.ipv4.ping_group_range="0 2147483647" \
  icmpengine:latest --dest 127.0.0.1 --count 3

Consumers can pull the binary via the overlay (inputs.icmpengine.overlays.defaultpkgs.icmpengine).

Note: the hermetic nix flake check runs only the socket-free unit tests; the loopback ICMP integration tests need privileges the Nix sandbox lacks and run in GitHub Actions instead.

Dependency licenses

Dependancy License Link
Golang BSD https://golang.org/LICENSE
github.com/google/btree v1.1.3 Apache 2.0 https://github.com/google/btree/blob/master/LICENSE
github.com/go-cmd/cmd v1.4.3 MIT https://github.com/go-cmd/cmd/blob/master/LICENSE
github.com/pkg/profile v1.7.0 BSD https://github.com/pkg/profile/blob/master/LICENSE
github.com/prometheus/client_golang v1.23.2 Apache 2.0 https://github.com/prometheus/client_golang/blob/master/LICENSE
golang.org/x/net v0.57.0 BSD https://golang.org/LICENSE

The IP type is the standard library net/netip, and logging is the standard library log/slog, so neither inet.af/netaddr nor hashicorp/go-hclog is a dependency any more.

The library needs only golang.org/x/net and github.com/google/btree (the latter tiny, with zero transitive dependencies). The remaining modules are used solely by the cmd/icmpengine CLI (profiling, Prometheus metrics, and the root sysctl helper), so embedding the library does not pull them in.

$ cat go.mod
module github.com/randomizedcoder/icmpengine

go 1.26.4

require (
	github.com/go-cmd/cmd v1.4.3
	github.com/google/btree v1.1.3
	github.com/pkg/profile v1.7.0
	github.com/prometheus/client_golang v1.23.2
	golang.org/x/net v0.57.0
)

How to tag

git tag
git tag -a v1.0.1 -m "v1.0.1"
git push origin --tags

Documentation

Overview

Package icmpengine sends non-privileged ICMP echo requests and receives the replies concurrently, without blocking on per-packet timeouts. It is designed to be embedded in other Go programs.

Non-privileged ICMP uses IPPROTO_ICMP sockets (see https://lwn.net/Articles/422330/). On Linux this requires the ping group range sysctl to include the running user's gid, e.g.:

sudo sysctl -w net.ipv4.ping_group_range="0 2147483647"

Typical use:

eng, err := icmpengine.New(icmpengine.WithLogger(logger))
if err != nil { ... }
if err := eng.Start(ctx); err != nil { ... }
defer eng.Close()
res, err := eng.Ping(ctx, netip.MustParseAddr("8.8.8.8"), 10, 100*time.Millisecond)

Index

Constants

View Source
const IsRaceEnabled = false

IsRaceEnabled reports if the race detector is enabled.

Variables

View Source
var (
	// ErrNotStarted is returned by Ping when the engine has not been Started
	// (or has already been Closed).
	ErrNotStarted = errors.New("icmpengine: engine not started")
	// ErrAlreadyStarted is returned by Start when called more than once.
	ErrAlreadyStarted = errors.New("icmpengine: engine already started")
	// ErrCountRange is returned by Ping when count is outside [0, 65535]
	// (ICMP sequence numbers are 16 bits).
	ErrCountRange = errors.New("icmpengine: count out of range [0,65535]")
	// ErrDuplicatePing is returned by Ping when another Ping to the same
	// address is already in flight on this engine.
	ErrDuplicatePing = errors.New("icmpengine: address already being pinged")
	// ErrInvalidAddr is returned by Ping when the destination address is not
	// a valid IPv4 or IPv6 address.
	ErrInvalidAddr = errors.New("icmpengine: invalid destination address")
	// ErrTimeoutRange is returned by Ping when a PingTimeout value is negative.
	ErrTimeoutRange = errors.New("icmpengine: ping timeout must be >= 0")
	// ErrPayloadSizeRange is returned by Ping when a PayloadSize value is
	// outside [0, maxPayloadSize].
	ErrPayloadSizeRange = errors.New("icmpengine: payload size out of range [0,65500]")
	// ErrDSCPRange is returned by Ping when a DSCP value is outside [0, 63].
	ErrDSCPRange = errors.New("icmpengine: dscp out of range [0,63]")
	// ErrTTLRange is returned by New when a WithTTL value is outside [0, 255]
	// (0 means keep the kernel default).
	ErrTTLRange = errors.New("icmpengine: ttl out of range [0,255]")
	// ErrDontFragmentUnsupported is returned by Start when WithDontFragment was
	// set on a platform where the engine cannot set it on non-privileged sockets
	// (everything except Linux).
	ErrDontFragmentUnsupported = errors.New("icmpengine: dont fragment is only supported on linux")
)

Sentinel errors returned by the engine.

Functions

This section is empty.

Types

type Backend

type Backend int

Backend selects the expiry-tracking data structure used by an Engine.

const (
	// BackendHeap uses container/heap, a binary min-heap. Peek is O(1); push and
	// remove are O(log n). No external dependency.
	BackendHeap Backend = iota
	// BackendBTree uses github.com/google/btree. All operations are O(log n).
	BackendBTree
	// BackendDaryHeap uses an 8-ary array heap — shallower than the binary heap,
	// which benchmarks fastest on this remove-heavy workload (see BenchmarkDaryFanout).
	// It is the default: New with no WithExpiryBackend uses it.
	BackendDaryHeap
	// BackendRadix uses a fixed-base MSD radix trie over the 64-bit expiry key;
	// all operations are O(64/8)=O(8), independent of n.
	BackendRadix
	// BackendPairing uses a pairing heap (O(1) push/peek).
	BackendPairing
	// BackendTimingWheel uses a hierarchical absolute-time timing wheel with a
	// btree overflow. Best under near-monotonic load; see expiry_wheel.go.
	BackendTimingWheel
)

func (Backend) String

func (b Backend) String() string

String implements fmt.Stringer for nicer logging and flag handling.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine sends ICMP echo requests and matches them to replies. Create one with New, Start it, call Ping/PingAll, then Close it. An Engine is safe for concurrent use by multiple goroutines. It implements io.Closer.

func New

func New(opts ...Option) (*Engine, error)

New creates an Engine from the given options. It validates configuration and returns an error instead of terminating the process. New does not open sockets or start goroutines; call Start for that.

func (*Engine) Close

func (e *Engine) Close() error

Close shuts the engine down: it cancels the engine context, waits for all in-flight pings and background goroutines to finish, and closes the sockets. Close is idempotent and returns the first fatal background error, if any.

func (*Engine) Err

func (e *Engine) Err() error

Err returns the first fatal error observed by a background (receiver) goroutine, or nil. A non-nil Err means the engine has begun shutting itself down.

func (*Engine) Ping

func (e *Engine) Ping(ctx context.Context, addr netip.Addr, count int, interval time.Duration, opts ...PingOption) (Result, error)

Ping sends count echo requests to addr, interval apart, and returns aggregated statistics. It blocks until every packet has been answered or timed out, or until ctx (or the engine) is canceled. On cancellation it returns the partial Result gathered so far alongside a non-nil error.

func (*Engine) PingAll

func (e *Engine) PingAll(ctx context.Context, concurrency int, targets []Target) ([]Result, error)

PingAll pings every target concurrently with at most concurrency pings in flight at once (concurrency <= 0 means one worker per target). Results are returned aligned to targets. It returns the first error encountered, if any; per-target partial results are still populated.

func (*Engine) Start

func (e *Engine) Start(ctx context.Context) error

Start opens the ICMP sockets and launches the receiver goroutines. The provided context governs the engine's lifetime: canceling it (or calling Close) shuts everything down. Start may be called only once.

type ICMPEchoReply

type ICMPEchoReply struct {
	Type       uint8
	Code       uint8
	Checksum   uint16
	Identifier uint16
	Seq        uint16
}

ICMPEchoReply represents Echo Reply messages per the IPv4/rfc792 and IPv6/rfc2463 ( see extended comments below )

func ParseICMPEchoReply

func ParseICMPEchoReply(b []byte) (*ICMPEchoReply, error)

ParseICMPEchoReply parses the ICMP echo reply messages This was originally based on on the the golang standard icmp ParseMessage, which for unknown reasons don't parse ICMP echo https://pkg.go.dev/golang.org/x/net/icmp#ParseMessage https://github.com/golang/net/blob/7fd8e65b6420/icmp/message.go#L139

func ParseICMPEchoReplyBB

func ParseICMPEchoReplyBB(b bytes.Buffer) (*ICMPEchoReply, error)

ParseICMPEchoReplyBB is the same as ParseICMPEchoReply, except uses bytes.Buffer, instead of []byte This is mostly to allow use of sync.Pool, which should be faster (maybe?) https://www.akshaydeo.com/blog/2017/12/23/How-did-I-improve-latency-by-700-percent-using-syncPool/

type Option

type Option func(*config)

Option configures an Engine created by New.

func WithDSCP

func WithDSCP(v int) Option

WithDSCP marks every echo request sent by this engine with the given 6-bit DiffServ code point (0-63). It is written into the IPv4 ToS / IPv6 Traffic Class byte as v<<2 (the low two ECN bits stay zero) and applied once, at Start, to both the IPv4 and IPv6 socket. It is engine-wide rather than per-ping because the engine shares one socket per family across all concurrent pings. Defaults to 0 (unmarked). Values outside [0, 63] are rejected by New.

func WithDontFragment added in v1.2.0

func WithDontFragment(b bool) Option

WithDontFragment sets the IPv4 Don't-Fragment bit (and the IPv6 equivalent) on outgoing echo requests by enabling path-MTU discovery on the socket (IP_MTU_DISCOVER / IPV6_MTU_DISCOVER = DO). Combined with WithPayloadSize it lets a caller probe the path MTU without privileged raw sockets. It is applied at Start; on platforms other than Linux, Start returns ErrDontFragmentUnsupported. Defaults to false.

func WithExpiryBackend

func WithExpiryBackend(b Backend) Option

WithExpiryBackend selects the data structure used to track outstanding pings. Defaults to BackendDaryHeap (the fastest in benchmarks); see docs/backends.md.

func WithFakeSuccess

func WithFakeSuccess(b bool) Option

WithFakeSuccess makes the engine synthesize successful replies without opening sockets or sending packets. Intended for testing only.

func WithHackSysctl

func WithHackSysctl(b bool) Option

WithHackSysctl allows the engine, when running as root, to run "sysctl -w net.ipv4.ping_group_range=0 2147483647" if opening sockets fails. Off by default; opt in only if you understand the implication.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the structured logger. A nil logger (the default) discards all log output.

func WithReadDeadline

func WithReadDeadline(d time.Duration) Option

WithReadDeadline sets the receiver socket read deadline. It bounds how quickly receivers notice Close/cancellation; it is not a per-ping timeout. Defaults to 1s.

func WithReceivers

func WithReceivers(v4, v6 int) Option

WithReceivers sets the number of receiver goroutines per protocol. Defaults to 2 and 2.

func WithSource added in v1.2.0

func WithSource(addr netip.Addr) Option

WithSource binds the engine's sockets to a specific source address, so echo requests leave from that address (useful on multi-homed hosts). The address applies to its own family only: an IPv4 source binds the IPv4 socket and leaves the IPv6 socket on its default (and vice versa). A zero Addr (the default) binds to the wildcard address. Binding is done at Start; an address that is not a local address makes Start fail.

func WithSplayReceivers

func WithSplayReceivers(b bool) Option

WithSplayReceivers staggers receiver startup over the read deadline instead of starting them all at once. Defaults to false.

func WithTTL

func WithTTL(n int) Option

WithTTL sets the outgoing IPv4 TTL (and the equivalent IPv6 hop limit) for every echo request sent by this engine. It is applied once, at Start, to both sockets — engine-wide rather than per-ping for the same reason as WithDSCP. Defaults to 64 (the Linux ping / kernel default); a value of 0 keeps the kernel default without setting the option. Values outside [0, 255] are rejected by New.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets how long to wait for an echo reply before a ping is counted as a failure. Defaults to 1s.

type PingOption

type PingOption func(*pingConfig)

PingOption customizes a single Ping call.

func DropProbability

func DropProbability(p float64) PingOption

DropProbability fakes packet loss with the given probability in [0,1] by not actually sending the echo request. Intended for testing only.

func PayloadSize

func PayloadSize(n int) PingOption

PayloadSize sets the number of ICMP data bytes appended after the 8-byte ICMP header, like ping's -s option (so PayloadSize(56) yields a 64-byte ICMP message). The kernel adds the IP header on top. A value of 0 (the default) sends a bare echo request. Values outside [0, 65500] are rejected by Ping.

func PingTimeout

func PingTimeout(d time.Duration) PingOption

PingTimeout overrides the engine's default timeout for this Ping call, so different destinations can wait different amounts of time before a packet is counted as a failure (e.g. 10ms on a LAN, hours for an interplanetary link). A zero value keeps the engine default; a negative value is rejected.

func SortRTTs

func SortRTTs() PingOption

SortRTTs sorts Result.RTTs ascending before returning.

type Result

type Result struct {
	IP         netip.Addr
	Successes  int
	Failures   int
	OutOfOrder int
	RTTs       []time.Duration
	Count      int
	Min        time.Duration
	Max        time.Duration
	Mean       time.Duration
	Variance   time.Duration
	Sum        time.Duration
	// Duration is the wall-clock time the Ping call took.
	Duration time.Duration
}

Result holds the aggregated statistics for a single Ping call.

type Target

type Target struct {
	Addr     netip.Addr
	Count    int
	Interval time.Duration
	Options  []PingOption
}

Target describes one destination for PingAll.

Directories

Path Synopsis
cmd
icmpengine command
example
concurrent command
Command concurrent pings many hosts in parallel with a bounded worker pool and prints a per-host summary.
Command concurrent pings many hosts in parallel with a bounded worker pool and prints a per-host summary.
simple command
Command simple pings a single host and prints the round-trip statistics.
Command simple pings a single host and prints the round-trip statistics.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL