This commit is contained in:
henrygd
2026-04-20 10:47:59 -04:00
parent e71ffd4d2a
commit 209bb4ebb4
2 changed files with 231 additions and 51 deletions

View File

@@ -12,12 +12,17 @@ import (
"golang.org/x/net/icmp" "golang.org/x/net/icmp"
"golang.org/x/net/ipv4" "golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"log/slog" "log/slog"
) )
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`) var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
type icmpPacketConn interface {
Close() error
}
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or // icmpMethod tracks which ICMP approach to use. Once a method succeeds or
// all native methods fail, the choice is cached so subsequent probes skip // all native methods fail, the choice is cached so subsequent probes skip
// the trial-and-error overhead. // the trial-and-error overhead.
@@ -25,77 +30,128 @@ type icmpMethod int
const ( const (
icmpUntried icmpMethod = iota // haven't tried yet icmpUntried icmpMethod = iota // haven't tried yet
icmpRaw // privileged raw socket (ip4:icmp) icmpRaw // privileged raw socket
icmpDatagram // unprivileged datagram socket (udp4) icmpDatagram // unprivileged datagram socket
icmpExecFallback // shell out to system ping command icmpExecFallback // shell out to system ping command
) )
// icmpFamily holds the network parameters and cached detection result for one address family.
type icmpFamily struct {
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
dgramNetwork string // e.g. "udp4" or "udp6"
listenAddr string // "0.0.0.0" or "::"
echoType icmp.Type // outgoing echo request type
replyType icmp.Type // expected echo reply type
proto int // IANA protocol number for parsing replies
isIPv6 bool
mode icmpMethod // cached detection result (guarded by icmpModeMu)
}
var ( var (
icmpMode icmpMethod icmpV4 = icmpFamily{
rawNetwork: "ip4:icmp",
dgramNetwork: "udp4",
listenAddr: "0.0.0.0",
echoType: ipv4.ICMPTypeEcho,
replyType: ipv4.ICMPTypeEchoReply,
proto: 1,
}
icmpV6 = icmpFamily{
rawNetwork: "ip6:ipv6-icmp",
dgramNetwork: "udp6",
listenAddr: "::",
echoType: ipv6.ICMPTypeEchoRequest,
replyType: ipv6.ICMPTypeEchoReply,
proto: 58,
isIPv6: true,
}
icmpModeMu sync.Mutex icmpModeMu sync.Mutex
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
return icmp.ListenPacket(network, listenAddr)
}
) )
// probeICMP sends an ICMP echo request and measures round-trip latency. // probeICMP sends an ICMP echo request and measures round-trip latency.
// It tries native raw socket first, then unprivileged datagram socket, // Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
// and falls back to the system ping command if both fail. // unprivileged datagram, or exec fallback) is detected once per address
// family and cached for subsequent probes.
// Returns latency in milliseconds, or -1 on failure. // Returns latency in milliseconds, or -1 on failure.
func probeICMP(target string) float64 { func probeICMP(target string) float64 {
family, ip := resolveICMPTarget(target)
if family == nil {
return -1
}
icmpModeMu.Lock() icmpModeMu.Lock()
mode := icmpMode if family.mode == icmpUntried {
family.mode = detectICMPMode(family, icmpListen)
}
mode := family.mode
icmpModeMu.Unlock() icmpModeMu.Unlock()
switch mode { switch mode {
case icmpRaw: case icmpRaw:
return probeICMPNative("ip4:icmp", "0.0.0.0", target) return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
case icmpDatagram: case icmpDatagram:
return probeICMPNative("udp4", "0.0.0.0", target) return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
case icmpExecFallback: case icmpExecFallback:
return probeICMPExec(target) return probeICMPExec(target, family.isIPv6)
default: default:
// First call — probe which method works return -1
return probeICMPDetect(target)
} }
} }
// probeICMPDetect tries each ICMP method in order and caches the first // resolveICMPTarget resolves a target hostname or IP to determine the address
// one that succeeds. // family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
func probeICMPDetect(target string) float64 { func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
// 1. Try privileged raw socket if ip := net.ParseIP(target); ip != nil {
if ms := probeICMPNative("ip4:icmp", "0.0.0.0", target); ms >= 0 { if ip.To4() != nil {
icmpModeMu.Lock() return &icmpV4, ip.To4()
icmpMode = icmpRaw }
icmpModeMu.Unlock() return &icmpV6, ip
slog.Info("ICMP probe using raw socket")
return ms
} }
// 2. Try unprivileged datagram socket (Linux/macOS) ips, err := net.LookupIP(target)
if ms := probeICMPNative("udp4", "0.0.0.0", target); ms >= 0 { if err != nil || len(ips) == 0 {
icmpModeMu.Lock() return nil, nil
icmpMode = icmpDatagram }
icmpModeMu.Unlock() for _, ip := range ips {
slog.Info("ICMP probe using unprivileged datagram socket") if v4 := ip.To4(); v4 != nil {
return ms return &icmpV4, v4
}
}
return &icmpV6, ips[0]
}
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
label := "IPv4"
if family.isIPv6 {
label = "IPv6"
} }
// 3. Fall back to system ping command if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
slog.Info("ICMP probe falling back to system ping command") conn.Close()
icmpModeMu.Lock() slog.Info("ICMP probe using raw socket", "family", label)
icmpMode = icmpExecFallback return icmpRaw
icmpModeMu.Unlock() } else {
return probeICMPExec(target) slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
}
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
conn.Close()
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
return icmpDatagram
} else {
slog.Debug("ICMP datagram socket unavailable", "family", label, "err", err)
}
slog.Info("ICMP probe falling back to system ping command", "family", label)
return icmpExecFallback
} }
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package. // probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
// network is "ip4:icmp" for raw sockets or "udp4" for unprivileged datagram sockets. func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
func probeICMPNative(network, listenAddr, target string) float64 { conn, err := icmp.ListenPacket(network, family.listenAddr)
// Resolve the target to an IP address
dst, err := net.ResolveIPAddr("ip4", target)
if err != nil {
return -1
}
conn, err := icmp.ListenPacket(network, listenAddr)
if err != nil { if err != nil {
return -1 return -1
} }
@@ -103,7 +159,7 @@ func probeICMPNative(network, listenAddr, target string) float64 {
// Build ICMP echo request // Build ICMP echo request
msg := &icmp.Message{ msg := &icmp.Message{
Type: ipv4.ICMPTypeEcho, Type: family.echoType,
Code: 0, Code: 0,
Body: &icmp.Echo{ Body: &icmp.Echo{
ID: os.Getpid() & 0xffff, ID: os.Getpid() & 0xffff,
@@ -132,14 +188,12 @@ func probeICMPNative(network, listenAddr, target string) float64 {
return -1 return -1
} }
// Parse the ICMP protocol number based on network type reply, err := icmp.ParseMessage(family.proto, buf[:n])
proto := 1 // ICMPv4
reply, err := icmp.ParseMessage(proto, buf[:n])
if err != nil { if err != nil {
return -1 return -1
} }
if reply.Type == ipv4.ICMPTypeEchoReply { if reply.Type == family.replyType {
return float64(time.Since(start).Microseconds()) / 1000.0 return float64(time.Since(start).Microseconds()) / 1000.0
} }
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading // Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
@@ -147,13 +201,21 @@ func probeICMPNative(network, listenAddr, target string) float64 {
} }
// probeICMPExec falls back to the system ping command. Returns -1 on failure. // probeICMPExec falls back to the system ping command. Returns -1 on failure.
func probeICMPExec(target string) float64 { func probeICMPExec(target string, isIPv6 bool) float64 {
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target) if isIPv6 {
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
} else {
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
}
default: // linux, darwin, freebsd default: // linux, darwin, freebsd
cmd = exec.Command("ping", "-c", "1", "-W", "3", target) if isIPv6 {
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
} else {
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
}
} }
start := time.Now() start := time.Now()

118
agent/probe_ping_test.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build testing
package agent
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testICMPPacketConn struct{}
func (testICMPPacketConn) Close() error { return nil }
func TestDetectICMPMode(t *testing.T) {
tests := []struct {
name string
family *icmpFamily
rawErr error
udpErr error
want icmpMethod
wantNetworks []string
}{
{
name: "IPv4 prefers raw socket when available",
family: &icmpV4,
want: icmpRaw,
wantNetworks: []string{"ip4:icmp"},
},
{
name: "IPv4 uses datagram when raw unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv4 falls back to exec when both unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv6 prefers raw socket when available",
family: &icmpV6,
want: icmpRaw,
wantNetworks: []string{"ip6:ipv6-icmp"},
},
{
name: "IPv6 uses datagram when raw unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
{
name: "IPv6 falls back to exec when both unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := make([]string, 0, 2)
listen := func(network, listenAddr string) (icmpPacketConn, error) {
require.Equal(t, tt.family.listenAddr, listenAddr)
calls = append(calls, network)
switch network {
case tt.family.rawNetwork:
if tt.rawErr != nil {
return nil, tt.rawErr
}
case tt.family.dgramNetwork:
if tt.udpErr != nil {
return nil, tt.udpErr
}
default:
t.Fatalf("unexpected network %q", network)
}
return testICMPPacketConn{}, nil
}
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
assert.Equal(t, tt.wantNetworks, calls)
})
}
}
func TestResolveICMPTarget(t *testing.T) {
t.Run("IPv4 literal", func(t *testing.T) {
family, ip := resolveICMPTarget("127.0.0.1")
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
t.Run("IPv6 literal", func(t *testing.T) {
family, ip := resolveICMPTarget("::1")
require.NotNil(t, family)
assert.True(t, family.isIPv6)
assert.Equal(t, "::1", ip.String())
})
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
}