From 4e6dd45415efbf4657f690dcd5a7f9524a25604c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 10:02:54 -0600 Subject: [PATCH] use raw ICMP ping with exec fallback for unprivileged environments --- go.mod | 1 + go.sum | 2 + ping.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++ ping_test.go | 27 ++++++++++++ 4 files changed, 143 insertions(+) diff --git a/go.mod b/go.mod index 25bb5c0..8823331 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( github.com/gosnmp/gosnmp v1.43.2 golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index a8070f5..3fd0d3b 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= diff --git a/ping.go b/ping.go index e2fb60a..a2f4904 100644 --- a/ping.go +++ b/ping.go @@ -4,14 +4,127 @@ import ( "context" "fmt" "net" + "os" "os/exec" "strconv" "strings" "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" ) +// icmpPing sends a single ICMP echo request and returns the round-trip time in milliseconds. +// Uses unprivileged UDP sockets where available, falling back to raw sockets. +func icmpPing(ip string, timeoutMs int) (float64, error) { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return 0, fmt.Errorf("invalid IP address: %s", ip) + } + + isIPv4 := parsedIP.To4() != nil + var network string + var dst net.Addr + var msgType icmp.Type + + if isIPv4 { + network = "udp4" + dst = &net.UDPAddr{IP: parsedIP} + msgType = ipv4.ICMPTypeEcho + } else { + network = "udp6" + dst = &net.UDPAddr{IP: parsedIP} + msgType = ipv6.ICMPTypeEchoRequest + } + + conn, err := icmp.ListenPacket(network, "") + if err != nil { + return 0, &errICMPUnavailable{err: fmt.Errorf("icmp listen: %w", err)} + } + defer func() { _ = conn.Close() }() + + id := os.Getpid() & 0xffff + msg := icmp.Message{ + Type: msgType, + Code: 0, + Body: &icmp.Echo{ + ID: id, + Seq: 1, + Data: []byte("towerops"), + }, + } + + var proto int + if isIPv4 { + proto = 1 // ICMP + } else { + proto = 58 // ICMPv6 + } + + wb, err := msg.Marshal(nil) + if err != nil { + return 0, fmt.Errorf("icmp marshal: %w", err) + } + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + if err := conn.SetDeadline(deadline); err != nil { + return 0, fmt.Errorf("set deadline: %w", err) + } + + start := time.Now() + if _, err := conn.WriteTo(wb, dst); err != nil { + return 0, fmt.Errorf("icmp write: %w", err) + } + + rb := make([]byte, 1500) + for { + n, _, err := conn.ReadFrom(rb) + if err != nil { + return 0, fmt.Errorf("icmp read: %w", err) + } + elapsed := time.Since(start) + + rm, err := icmp.ParseMessage(proto, rb[:n]) + if err != nil { + continue + } + + switch rm.Type { + case ipv4.ICMPTypeEchoReply, ipv6.ICMPTypeEchoReply: + echo, ok := rm.Body.(*icmp.Echo) + if !ok || echo.ID != id { + continue + } + return float64(elapsed.Microseconds()) / 1000.0, nil + } + } +} + +// errICMPUnavailable is returned when the ICMP socket can't be opened. +// This triggers a fallback to exec-based ping. +type errICMPUnavailable struct{ err error } + +func (e *errICMPUnavailable) Error() string { return e.err.Error() } + // pingDevice pings an IP address and returns the response time in milliseconds. +// Tries raw ICMP first for efficiency, falls back to exec-based ping only +// if the system doesn't support unprivileged ICMP. func pingDevice(ip string, timeoutMs int) (float64, error) { + ms, err := icmpPing(ip, timeoutMs) + if err == nil { + return ms, nil + } + + // Only fall back to exec if ICMP sockets aren't available + if _, ok := err.(*errICMPUnavailable); ok { + return execPing(ip, timeoutMs) + } + return 0, err +} + +// execPing uses the system ping command as a fallback. +func execPing(ip string, timeoutMs int) (float64, error) { parsedIP := net.ParseIP(ip) if parsedIP == nil { return 0, fmt.Errorf("invalid IP address: %s", ip) diff --git a/ping_test.go b/ping_test.go index a5c5054..e5b2fb5 100644 --- a/ping_test.go +++ b/ping_test.go @@ -38,6 +38,33 @@ func TestPingDeviceIPv6(t *testing.T) { } } +func TestIcmpPingLocalhost(t *testing.T) { + ms, err := icmpPing("127.0.0.1", 5000) + if err != nil { + t.Skipf("ICMP not available: %v", err) + } + if ms <= 0 { + t.Errorf("expected positive response time, got %v", ms) + } +} + +func TestIcmpPingIPv6(t *testing.T) { + ms, err := icmpPing("::1", 5000) + if err != nil { + t.Skipf("IPv6 ICMP not available: %v", err) + } + if ms <= 0 { + t.Errorf("expected positive response time, got %v", ms) + } +} + +func TestIcmpPingInvalidIP(t *testing.T) { + _, err := icmpPing("not-an-ip", 5000) + if err == nil { + t.Error("expected error for invalid IP") + } +} + func TestParsePingTime(t *testing.T) { tests := []struct { name string