use raw ICMP ping with exec fallback for unprivileged environments
This commit is contained in:
parent
94192f402a
commit
4e6dd45415
4 changed files with 143 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.6
|
||||||
require (
|
require (
|
||||||
github.com/gosnmp/gosnmp v1.43.2
|
github.com/gosnmp/gosnmp v1.43.2
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/net v0.50.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
go.sum
2
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=
|
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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
|
|
||||||
113
ping.go
113
ping.go
|
|
@ -4,14 +4,127 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.
|
// 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) {
|
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)
|
parsedIP := net.ParseIP(ip)
|
||||||
if parsedIP == nil {
|
if parsedIP == nil {
|
||||||
return 0, fmt.Errorf("invalid IP address: %s", ip)
|
return 0, fmt.Errorf("invalid IP address: %s", ip)
|
||||||
|
|
|
||||||
27
ping_test.go
27
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) {
|
func TestParsePingTime(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue