From 209bb4ebb483a379fa4b72239eda5d6c9bcb3b2c Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 20 Apr 2026 10:47:59 -0400 Subject: [PATCH] update --- agent/probe_ping.go | 164 +++++++++++++++++++++++++++------------ agent/probe_ping_test.go | 118 ++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 51 deletions(-) create mode 100644 agent/probe_ping_test.go diff --git a/agent/probe_ping.go b/agent/probe_ping.go index 06a620b8..9011e67f 100644 --- a/agent/probe_ping.go +++ b/agent/probe_ping.go @@ -12,12 +12,17 @@ import ( "golang.org/x/net/icmp" "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" "log/slog" ) 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 // all native methods fail, the choice is cached so subsequent probes skip // the trial-and-error overhead. @@ -25,77 +30,128 @@ type icmpMethod int const ( icmpUntried icmpMethod = iota // haven't tried yet - icmpRaw // privileged raw socket (ip4:icmp) - icmpDatagram // unprivileged datagram socket (udp4) + icmpRaw // privileged raw socket + icmpDatagram // unprivileged datagram socket 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 ( - 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 + icmpListen = func(network, listenAddr string) (icmpPacketConn, error) { + return icmp.ListenPacket(network, listenAddr) + } ) // probeICMP sends an ICMP echo request and measures round-trip latency. -// It tries native raw socket first, then unprivileged datagram socket, -// and falls back to the system ping command if both fail. +// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket, +// unprivileged datagram, or exec fallback) is detected once per address +// family and cached for subsequent probes. // Returns latency in milliseconds, or -1 on failure. func probeICMP(target string) float64 { + family, ip := resolveICMPTarget(target) + if family == nil { + return -1 + } + icmpModeMu.Lock() - mode := icmpMode + if family.mode == icmpUntried { + family.mode = detectICMPMode(family, icmpListen) + } + mode := family.mode icmpModeMu.Unlock() switch mode { case icmpRaw: - return probeICMPNative("ip4:icmp", "0.0.0.0", target) + return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip}) case icmpDatagram: - return probeICMPNative("udp4", "0.0.0.0", target) + return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip}) case icmpExecFallback: - return probeICMPExec(target) + return probeICMPExec(target, family.isIPv6) default: - // First call — probe which method works - return probeICMPDetect(target) + return -1 } } -// probeICMPDetect tries each ICMP method in order and caches the first -// one that succeeds. -func probeICMPDetect(target string) float64 { - // 1. Try privileged raw socket - if ms := probeICMPNative("ip4:icmp", "0.0.0.0", target); ms >= 0 { - icmpModeMu.Lock() - icmpMode = icmpRaw - icmpModeMu.Unlock() - slog.Info("ICMP probe using raw socket") - return ms +// resolveICMPTarget resolves a target hostname or IP to determine the address +// family and concrete IP address. Prefers IPv4 for dual-stack hostnames. +func resolveICMPTarget(target string) (*icmpFamily, net.IP) { + if ip := net.ParseIP(target); ip != nil { + if ip.To4() != nil { + return &icmpV4, ip.To4() + } + return &icmpV6, ip } - // 2. Try unprivileged datagram socket (Linux/macOS) - if ms := probeICMPNative("udp4", "0.0.0.0", target); ms >= 0 { - icmpModeMu.Lock() - icmpMode = icmpDatagram - icmpModeMu.Unlock() - slog.Info("ICMP probe using unprivileged datagram socket") - return ms + ips, err := net.LookupIP(target) + if err != nil || len(ips) == 0 { + return nil, nil + } + for _, ip := range ips { + if v4 := ip.To4(); v4 != nil { + 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 - slog.Info("ICMP probe falling back to system ping command") - icmpModeMu.Lock() - icmpMode = icmpExecFallback - icmpModeMu.Unlock() - return probeICMPExec(target) + if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil { + conn.Close() + slog.Info("ICMP probe using raw socket", "family", label) + return icmpRaw + } else { + 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. -// network is "ip4:icmp" for raw sockets or "udp4" for unprivileged datagram sockets. -func probeICMPNative(network, listenAddr, target string) float64 { - // Resolve the target to an IP address - dst, err := net.ResolveIPAddr("ip4", target) - if err != nil { - return -1 - } - - conn, err := icmp.ListenPacket(network, listenAddr) +func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 { + conn, err := icmp.ListenPacket(network, family.listenAddr) if err != nil { return -1 } @@ -103,7 +159,7 @@ func probeICMPNative(network, listenAddr, target string) float64 { // Build ICMP echo request msg := &icmp.Message{ - Type: ipv4.ICMPTypeEcho, + Type: family.echoType, Code: 0, Body: &icmp.Echo{ ID: os.Getpid() & 0xffff, @@ -132,14 +188,12 @@ func probeICMPNative(network, listenAddr, target string) float64 { return -1 } - // Parse the ICMP protocol number based on network type - proto := 1 // ICMPv4 - reply, err := icmp.ParseMessage(proto, buf[:n]) + reply, err := icmp.ParseMessage(family.proto, buf[:n]) if err != nil { return -1 } - if reply.Type == ipv4.ICMPTypeEchoReply { + if reply.Type == family.replyType { return float64(time.Since(start).Microseconds()) / 1000.0 } // 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. -func probeICMPExec(target string) float64 { +func probeICMPExec(target string, isIPv6 bool) float64 { var cmd *exec.Cmd switch runtime.GOOS { 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 - 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() diff --git a/agent/probe_ping_test.go b/agent/probe_ping_test.go new file mode 100644 index 00000000..2df0feb0 --- /dev/null +++ b/agent/probe_ping_test.go @@ -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()) + }) +}