Files
statuspanel/internal/probe/ping.go
2026-07-05 14:26:03 -03:00

154 lines
3.8 KiB
Go

package probe
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"time"
)
const (
icmpEchoReply = 0
icmpEcho = 8
)
// PingResult contains the outcome of one ICMP echo request.
type PingResult struct {
Reachable bool
RTT time.Duration
Err error
}
// Ping sends one ICMP echo request to an IPv4 host.
func Ping(ctx context.Context, host string, timeout time.Duration) (result PingResult) {
start := time.Now()
ip, err := resolveIPv4(ctx, host)
if err != nil {
return PingResult{Err: err}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return PingResult{Err: fmt.Errorf("opening icmp socket: %w", err)}
}
defer func() {
if closeErr := conn.Close(); closeErr != nil && result.Err == nil {
result = PingResult{Err: fmt.Errorf("closing icmp socket: %w", closeErr)}
}
}()
deadline := time.Now().Add(timeout)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
if err := conn.SetDeadline(deadline); err != nil {
return PingResult{Err: fmt.Errorf("setting icmp deadline: %w", err)}
}
id, seq, err := echoIdentifiers()
if err != nil {
return PingResult{Err: err}
}
packet := echoPacket(id, seq)
if _, err := conn.WriteTo(packet, &net.IPAddr{IP: ip}); err != nil {
return PingResult{Err: fmt.Errorf("sending icmp echo to %s: %w", host, err)}
}
buffer := make([]byte, 1500)
for {
n, addr, err := conn.ReadFrom(buffer)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return PingResult{Err: fmt.Errorf("ping canceled: %w", ctxErr)}
}
return PingResult{Err: fmt.Errorf("reading icmp reply from %s: %w", host, err)}
}
if !sameIP(addr, ip) {
continue
}
if echoReplyMatches(buffer[:n], id, seq) {
return PingResult{Reachable: true, RTT: time.Since(start)}
}
}
}
func resolveIPv4(ctx context.Context, host string) (net.IP, error) {
if parsed := net.ParseIP(host); parsed != nil {
if ipv4 := parsed.To4(); ipv4 != nil {
return ipv4, nil
}
return nil, fmt.Errorf("%s is not an IPv4 address", host)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving ping host %s: %w", host, err)
}
for _, addr := range addrs {
if ipv4 := addr.IP.To4(); ipv4 != nil {
return ipv4, nil
}
}
return nil, fmt.Errorf("resolving ping host %s: no IPv4 address found", host)
}
func echoIdentifiers() (uint16, uint16, error) {
var data [4]byte
if _, err := rand.Read(data[:]); err != nil {
return 0, 0, fmt.Errorf("generating icmp identifiers: %w", err)
}
return binary.BigEndian.Uint16(data[0:2]), binary.BigEndian.Uint16(data[2:4]), nil
}
func echoPacket(id uint16, seq uint16) []byte {
payload := []byte("statuspanel")
packet := make([]byte, 8+len(payload))
packet[0] = icmpEcho
binary.BigEndian.PutUint16(packet[4:6], id)
binary.BigEndian.PutUint16(packet[6:8], seq)
copy(packet[8:], payload)
checksum := icmpChecksum(packet)
binary.BigEndian.PutUint16(packet[2:4], checksum)
return packet
}
func echoReplyMatches(packet []byte, id uint16, seq uint16) bool {
if len(packet) < 8 {
return false
}
if packet[0] == 69 && len(packet) >= 28 {
packet = packet[20:]
}
return len(packet) >= 8 &&
packet[0] == icmpEchoReply &&
binary.BigEndian.Uint16(packet[4:6]) == id &&
binary.BigEndian.Uint16(packet[6:8]) == seq
}
func sameIP(addr net.Addr, ip net.IP) bool {
ipAddr, ok := addr.(*net.IPAddr)
if !ok {
return false
}
return ipAddr.IP.Equal(ip)
}
func icmpChecksum(data []byte) uint16 {
var sum uint32
for len(data) > 1 {
sum += uint32(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
}
if len(data) == 1 {
sum += uint32(data[0]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
var errUnsupportedGateway = errors.New("gateway discovery is unsupported on this platform")