Files
worker/checks/cping/ping.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

379 строки
10 KiB
Go

// Package cping provides ICMP echo (ping) check functionality for RSMon.
//
// The implementation targets minimal-reuse logic inspired by
// github.com/go-ping/ping, but rewritten inline so the project does not
// pick up an external dependency. Concretely it uses
// golang.org/x/net/icmp to send an Echo Request and waits for a single
// Echo Reply within the configured timeout.
//
// Privileges / CAP_NET_RAW:
//
// - On Linux, the "ip4:icmp" listener needs either CAP_NET_RAW on the
// binary OR the net.ipv4.ping_group_range sysctl to be widened
// (see "unprivileged ICMP sockets"). When neither is available, we
// fall back to "udp4" which works on Linux only when the same
// sysctl is widened; on macOS the "udp4" mode is unprivileged by
// default.
// - On Windows the privileged (raw) ICMP listener is required.
//
// The check returns FAIL when neither listener can be opened so the
// caller knows the operator needs to enable the capability. OK/ERR are
// reported when the listener works but the host is unreachable or times
// out.
package cping
import (
"errors"
"fmt"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"rocketgit.ru/rsmon/worker/app/models"
)
const (
stateOK = "OK"
stateERR = "ERR"
stateFail = "FAIL"
defaultCount = 1
defaultTimeout = 5 * time.Second
minTimeout = 1 * time.Second
defaultPacketSz = 56
icmpProtoIP4 = "ip4:icmp"
icmpProtoUDP = "udp4"
)
// Pinger is the abstraction the package uses for ICMP echo. The
// production code path uses runPinger, but it is interface-typed so
// tests can substitute a fake without touching raw sockets.
type Pinger interface {
Run(host string, count int, timeout time.Duration, payloadSize int) Stats
}
// Stats is the per-run summary produced by a Pinger.
type Stats struct {
PacketsSent int
PacketsRecv int
AvgRtt time.Duration
Err error
}
var (
defaultPinger Pinger = &realPinger{}
// pingerMu guards the swap of defaultPinger in tests.
pingerMu sync.RWMutex
)
// SetPinger overrides the default Pinger. It is intended for tests
// that need to inject fakes without granting CAP_NET_RAW to the test
// binary.
func SetPinger(p Pinger) {
pingerMu.Lock()
defaultPinger = p
pingerMu.Unlock()
}
func currentPinger() Pinger {
pingerMu.RLock()
defer pingerMu.RUnlock()
return defaultPinger
}
// Perform executes a single ping check for the supplied Check.
//
// Settings consumed from models.CheckSettings:
// - count (int): number of echo requests to send. Defaults to 1 to
// keep intervals short; clamped to [1, 5].
// - timeout (int, seconds): total budget for the check; clamped to
// at least 1s.
// - packet_size (int): ICMP payload size in bytes; clamped to
// [0, 1400].
// - host (string): optional override of the monitor host.
func Perform(c *models.Check) *Result {
r := &Result{}
settings := c.GetSettings()
host := c.Monitor.Host
if settings.Host != "" {
host = settings.Host
}
if host == "" {
r.State = stateFail
r.Error = errors.New("ping: empty host")
return r
}
count := settings.Count
if count <= 0 {
count = defaultCount
}
if count > 5 {
count = 5
}
timeout := time.Duration(settings.Timeout) * time.Second
if timeout <= 0 {
timeout = defaultTimeout
}
if timeout < minTimeout {
timeout = minTimeout
}
payloadSize := settings.PacketSize
if payloadSize == 0 {
payloadSize = defaultPacketSz
}
if payloadSize < 0 {
payloadSize = 0
}
if payloadSize > 1400 {
payloadSize = 1400
}
start := time.Now()
stats := currentPinger().Run(host, count, timeout, payloadSize)
r.Duration = time.Since(start)
r.PacketsSent = stats.PacketsSent
r.PacketsRecv = stats.PacketsRecv
if stats.PacketsRecv > 0 {
r.AvgRttMs = float64(stats.AvgRtt.Microseconds()) / 1000.0
}
if stats.Err != nil {
// Distinguish "could not run at all" (no privileges) from
// "ran but failed" so the operator can fix the environment.
if isUnsupported(stats.Err) {
r.State = stateFail
} else {
r.State = stateERR
}
r.Error = stats.Err
return r
}
if stats.PacketsRecv == 0 {
r.State = stateERR
r.Error = fmt.Errorf("no reply from %s (sent %d)", host, stats.PacketsSent)
return r
}
r.State = stateOK
r.Infos = append(r.Infos, fmt.Sprintf("rtt=%.2fms sent=%d recv=%d", r.AvgRttMs, stats.PacketsSent, stats.PacketsRecv))
return r
}
// isUnsupported reports whether err looks like a permission problem
// rather than a runtime failure.
func isUnsupported(err error) bool {
if err == nil {
return false
}
msg := err.Error()
if runtime.GOOS == "windows" {
return msg != ""
}
// Linux/Darwin permission flavors.
if errors.Is(err, os.ErrPermission) {
return true
}
if msg == "" {
return false
}
if containsAny(msg, "operation not permitted", "permission denied", "cap_net_raw", "ping_group_range") {
return true
}
return false
}
func containsAny(s string, needles ...string) bool {
for _, n := range needles {
if n == "" {
continue
}
if strings.Contains(s, n) {
return true
}
}
return false
}
// realPinger sends and receives ICMP echo packets using the standard
// library plus golang.org/x/net/icmp.
type realPinger struct{}
// Run is the production pinger entrypoint. It resolves host, opens an
// ICMP listener (preferring the unprivileged UDP path on Linux when
// available, falling back to raw IP), and waits up to timeout for at
// least one Echo Reply.
func (r *realPinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats {
stats := Stats{PacketsSent: 0, PacketsRecv: 0}
dst, err := net.ResolveIPAddr("ip4", host)
if err != nil {
stats.Err = fmt.Errorf("resolve %s: %w", host, err)
return stats
}
conn, network, err := openICMP()
if err != nil {
stats.Err = err
return stats
}
defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close
// Build an "echo and wait for reply" function keyed by network so
// the IPv4-only logic stays close to where it is used.
sendAndRecv := func(seq int) (time.Duration, error) {
msg := icmp.Message{
Type: ipv4.ICMPTypeEcho, Code: 0,
Body: &icmp.Echo{
ID: os.Getpid() & 0xffff, Seq: seq,
Data: makeBytes(payloadSize),
},
}
bin, err := msg.Marshal(nil)
if err != nil {
return 0, fmt.Errorf("marshal icmp: %w", err)
}
sentAt := time.Now()
if _, err := conn.WriteTo(bin, dst); err != nil {
return 0, fmt.Errorf("write icmp: %w", err)
}
// Per-packet deadline = remaining budget / remaining attempts
// (or 1s minimum). We reuse the single shared conn for all
// count iterations so replies may arrive slightly out of order.
deadline := time.Now().Add(timeout / time.Duration(count))
if remaining := time.Until(deadline); remaining < time.Second {
deadline = time.Now().Add(time.Second)
}
if err := conn.SetReadDeadline(deadline); err != nil {
return 0, fmt.Errorf("set deadline: %w", err)
}
reply, peer, err := readOne(conn, network)
if err != nil {
return 0, err
}
_ = peer // peer would be useful for response-time per hop; not needed for MVP.
if reply == nil {
return 0, errors.New("nil reply")
}
return time.Since(sentAt), nil
}
var total time.Duration
for i := 1; i <= count; i++ {
stats.PacketsSent++
rtt, err := sendAndRecv(i)
if err != nil {
// First packet failed because of read timeout: report
// host as unreachable. Keep iterating up to count so the
// reported packet loss is accurate (>= 50%).
if i == 1 {
stats.Err = fmt.Errorf("icmp %s: %w", host, err)
}
continue
}
stats.PacketsRecv++
total += rtt
}
if stats.PacketsRecv > 0 {
stats.AvgRtt = total / time.Duration(stats.PacketsRecv)
}
// When at least one packet succeeded, drop the underlying error so
// Perform() reports OK.
if stats.PacketsRecv > 0 {
stats.Err = nil
}
return stats
}
// openICMP returns an ICMP packet connection. On Linux the code prefers
// the "udp4" (unprivileged) listener because the raw "ip4:icmp"
// listener needs CAP_NET_RAW unless the sysctl
// net.ipv4.ping_group_range is widened. On other OSes we fall back to
// the raw listener.
func openICMP() (*icmp.PacketConn, string, error) {
// Try the unprivileged path first; if it fails, fall back to raw.
conn, err := icmp.ListenPacket(icmpProtoUDP, "0.0.0.0")
if err == nil {
return conn, icmpProtoUDP, nil
}
rawErr := err
if runtime.GOOS == "windows" {
// Windows must use the raw (privileged) listener.
conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0")
if err != nil {
return nil, "", fmt.Errorf("icmp listen: %w", rawErr)
}
return conn, icmpProtoIP4, nil
}
// Linux/Darwin: try raw as a fallback. Production binaries
// shipping with cap_net_raw=+ep will succeed here; test/CI
// runners without the capability will surface a clear
// permission error.
conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0")
if err != nil {
return nil, "", fmt.Errorf("icmp listen (udp4=%v; ip4=%v)", rawErr, err)
}
return conn, icmpProtoIP4, nil
}
// readOne reads a single packet, validates it is an Echo Reply (or a
// TTL-exceeded reply from a router along the path), and returns it.
func readOne(conn *icmp.PacketConn, network string) (*icmp.Message, net.Addr, error) {
buf := make([]byte, 1500)
n, peer, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil, fmt.Errorf("read icmp: %w", err)
}
parsed, err := icmp.ParseMessage(icmpProtoToInt(network), buf[:n])
if err != nil {
return nil, nil, fmt.Errorf("parse icmp: %w", err)
}
// We accept both Echo Reply (the destination) and Time Exceeded
// (intermediate router hop) because some networks filter Echo
// Replies but still return traceroute-style Time Exceeded packets,
// which proves the host is reachable.
switch parsed.Type {
case ipv4.ICMPTypeEchoReply, ipv4.ICMPTypeTimeExceeded:
return parsed, peer, nil
}
return nil, nil, fmt.Errorf("unexpected icmp type %v", parsed.Type)
}
// icmpProtoToInt maps our internal "ip4:icmp"/"udp4" tag to the
// protocol number expected by icmp.ParseMessage. icmp.DefaultPacketProtocol
// would re-derive this but we want the value stable.
func icmpProtoToInt(probe string) int {
if probe == icmpProtoUDP {
return 1 // udp4
}
return 0 // ip4:icmp
}
// makeBytes returns a deterministic payload of the requested size so
// packet sizes are stable across runs.
func makeBytes(n int) []byte {
if n <= 0 {
return []byte{}
}
b := make([]byte, n)
for i := range b {
b[i] = byte('a' + (i % 26))
}
return b
}