mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 12:11:49 +02:00
feat(agent): use native ICMP sockets with fallback to system ping
Replace the ping-command-only implementation with a three-tier approach using golang.org/x/net/icmp: 1. Raw socket (ip4:icmp) — works with root or CAP_NET_RAW 2. Unprivileged datagram socket (udp4) — works on Linux/macOS without special privileges 3. System ping command — fallback when neither socket works The method is auto-detected on first probe and cached for all subsequent calls, avoiding repeated failed attempts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,153 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||||
|
|
||||||
// probeICMP executes system ping command and parses latency. Returns -1 on failure.
|
// 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.
|
||||||
|
type icmpMethod int
|
||||||
|
|
||||||
|
const (
|
||||||
|
icmpUntried icmpMethod = iota // haven't tried yet
|
||||||
|
icmpRaw // privileged raw socket (ip4:icmp)
|
||||||
|
icmpDatagram // unprivileged datagram socket (udp4)
|
||||||
|
icmpExecFallback // shell out to system ping command
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
icmpMode icmpMethod
|
||||||
|
icmpModeMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Returns latency in milliseconds, or -1 on failure.
|
||||||
func probeICMP(target string) float64 {
|
func probeICMP(target string) float64 {
|
||||||
|
icmpModeMu.Lock()
|
||||||
|
mode := icmpMode
|
||||||
|
icmpModeMu.Unlock()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case icmpRaw:
|
||||||
|
return probeICMPNative("ip4:icmp", "0.0.0.0", target)
|
||||||
|
case icmpDatagram:
|
||||||
|
return probeICMPNative("udp4", "0.0.0.0", target)
|
||||||
|
case icmpExecFallback:
|
||||||
|
return probeICMPExec(target)
|
||||||
|
default:
|
||||||
|
// First call — probe which method works
|
||||||
|
return probeICMPDetect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Build ICMP echo request
|
||||||
|
msg := &icmp.Message{
|
||||||
|
Type: ipv4.ICMPTypeEcho,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: os.Getpid() & 0xffff,
|
||||||
|
Seq: 1,
|
||||||
|
Data: []byte("beszel-probe"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBytes, err := msg.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set deadline before sending
|
||||||
|
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ICMP protocol number based on network type
|
||||||
|
proto := 1 // ICMPv4
|
||||||
|
reply, err := icmp.ParseMessage(proto, buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.Type == ipv4.ICMPTypeEchoReply {
|
||||||
|
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
|
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
|
||||||
|
func probeICMPExec(target string) float64 {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -20,6 +20,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||||
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
@@ -56,7 +57,6 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.38.0 // indirect
|
golang.org/x/image v0.38.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
|
|||||||
Reference in New Issue
Block a user