Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
379 строки
10 KiB
Go
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
|
|
}
|