// 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 }