mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 20:21:49 +02:00
update
This commit is contained in:
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fall back to system ping command
|
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||||
slog.Info("ICMP probe falling back to system ping command")
|
label := "IPv4"
|
||||||
icmpModeMu.Lock()
|
if family.isIPv6 {
|
||||||
icmpMode = icmpExecFallback
|
label = "IPv6"
|
||||||
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.
|
// 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,14 +201,22 @@ 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":
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||||
|
} else {
|
||||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||||
|
}
|
||||||
default: // linux, darwin, freebsd
|
default: // linux, darwin, freebsd
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||||
|
} else {
|
||||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
118
agent/probe_ping_test.go
Normal file
118
agent/probe_ping_test.go
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user