feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

378
checks/cping/ping.go Обычный файл
Просмотреть файл

@@ -0,0 +1,378 @@
// 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"
"rsgit.ru/rsmon/rsmon/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
}

159
checks/cping/ping_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,159 @@
package cping
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gorm.io/datatypes"
"rsgit.ru/rsmon/rsmon/app/models"
)
// settings mirrors models.CheckSettings so the test can build a
// CheckSettings JSON without pulling in the entire model package.
type settings struct {
Host string `json:"host,omitempty"`
Count int `json:"count,omitempty"`
Timeout int `json:"timeout,omitempty"`
PacketSize int `json:"packet_size,omitempty"`
Port string `json:"port,omitempty"`
}
func newCheck(t *testing.T, s settings, host string) *models.Check {
t.Helper()
raw, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
if host == "" {
host = "127.0.0.1"
}
return &models.Check{
Kind: "ping",
Monitor: &models.Monitor{Host: host},
Settings: datatypes.JSON(raw),
}
}
// fakePinger returns canned stats without touching the network so the
// state machine in Perform() can be exercised in CI environments
// without CAP_NET_RAW.
type fakePinger struct {
stats Stats
}
func (f *fakePinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats {
return f.stats
}
func TestPerformOK(t *testing.T) {
SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 3, AvgRtt: 4 * time.Millisecond}})
defer SetPinger(&realPinger{})
c := newCheck(t, settings{Count: 3}, "")
r := Perform(c)
if r.State != stateOK {
t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error)
}
if r.PacketsSent != 3 || r.PacketsRecv != 3 {
t.Fatalf("packet counts wrong: sent=%d recv=%d", r.PacketsSent, r.PacketsRecv)
}
if r.AvgRttMs < 1 {
t.Fatalf("expected RTT > 0, got %v", r.AvgRttMs)
}
if len(r.Infos) == 0 || !strings.Contains(r.Infos[0], "rtt=") {
t.Fatalf("expected rtt info line, got %v", r.Infos)
}
}
func TestPerformNoReply(t *testing.T) {
SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 0, Err: errors.New("i/o timeout")}})
defer SetPinger(&realPinger{})
c := newCheck(t, settings{}, "")
r := Perform(c)
if r.State != stateERR {
t.Fatalf("expected ERR, got %s", r.State)
}
if r.PacketsSent == 0 {
t.Fatalf("expected PacketsSent to be incremented even on failure")
}
if r.Error == nil {
t.Fatalf("expected error, got nil")
}
}
func TestPerformUnsupported(t *testing.T) {
SetPinger(&fakePinger{stats: Stats{Err: errors.New("socket: operation not permitted (cap_net_raw)")}})
defer SetPinger(&realPinger{})
c := newCheck(t, settings{}, "")
r := Perform(c)
if r.State != stateFail {
t.Fatalf("expected FAIL when raw ICMP is not allowed, got %s", r.State)
}
}
func TestPerformEmptyHost(t *testing.T) {
c := &models.Check{
Kind: "ping",
Monitor: &models.Monitor{Host: ""},
Settings: datatypes.JSON("{}"),
}
r := Perform(c)
if r.State != stateFail {
t.Fatalf("expected FAIL on empty host, got %s", r.State)
}
}
func TestIsUnsupported(t *testing.T) {
cases := []struct {
err error
want bool
}{
{err: nil, want: false},
{err: errors.New(""), want: false},
{err: errors.New("permission denied"), want: true},
{err: errors.New("icmp listen: cap_net_raw required"), want: true},
{err: errors.New("i/o timeout"), want: false},
{err: errors.New("no route to host"), want: false},
}
for _, tc := range cases {
if got := isUnsupported(tc.err); got != tc.want {
t.Errorf("isUnsupported(%q) = %v, want %v", tc.err, got, tc.want)
}
}
}
func TestLossPercent(t *testing.T) {
cases := []struct {
sent, recv int
want int64
}{
{sent: 0, recv: 0, want: 0},
{sent: 5, recv: 5, want: 0},
{sent: 5, recv: 3, want: 40},
{sent: 5, recv: 0, want: 100},
}
for _, tc := range cases {
if got := lossPercent(tc.sent, tc.recv); got != tc.want {
t.Errorf("lossPercent(%d,%d) = %d, want %d", tc.sent, tc.recv, got, tc.want)
}
}
}
func TestMakeBytes(t *testing.T) {
if got := makeBytes(0); len(got) != 0 {
t.Fatalf("expected empty slice, got %d bytes", len(got))
}
if got := makeBytes(-1); len(got) != 0 {
t.Fatalf("expected empty slice for negative size, got %d bytes", len(got))
}
got := makeBytes(3)
if len(got) != 3 || string(got) != "abc" {
t.Fatalf("expected 'abc', got %q", string(got))
}
}

60
checks/cping/result.go Обычный файл
Просмотреть файл

@@ -0,0 +1,60 @@
package cping
import (
"strconv"
"strings"
"time"
"rsgit.ru/rsmon/rsmon/app/models"
"rsgit.ru/rsmon/rsmon/internal/checkresult"
)
// Result is the outcome of a single ping check. It embeds
// checkresult.CheckResult for the standard fields (State, Error,
// Duration, Warnings, Infos) and adds Ping-specific metrics consumed
// by the metrics writers when the check produces influx telemetry.
type Result struct {
checkresult.CheckResult
PacketsSent int
PacketsRecv int
AvgRttMs float64
}
// InfluxFields reports ping metrics in the same shape as the rest of
// the check packages: "took" is elapsed ms (for graphs and alerts).
func (r *Result) InfluxFields() map[string]interface{} {
ret := make(map[string]interface{}, 0)
ret["took"] = int64(r.Duration / time.Millisecond)
ret["packets_sent"] = r.PacketsSent
ret["packets_recv"] = r.PacketsRecv
ret["packet_loss"] = lossPercent(r.PacketsSent, r.PacketsRecv)
if r.AvgRttMs > 0 {
ret["rtt_ms"] = int64(r.AvgRttMs)
}
return ret
}
// InfluxTags returns the standard set of tags used by the chttp/cdns
// packages. The "state" tag is taken from the embedded CheckResult
// after Perform() has populated it so the writer sees the actual
// final state (OK/ERR/FAIL/WARN).
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
ret := make(map[string]string, 0)
ret["check"] = strconv.FormatInt(c.ID, 10)
ret["state"] = r.State
if r.Error != nil {
ret["error"] = r.Error.Error()
}
ret["warnings"] = strings.Join(r.Warnings, ",")
return ret
}
func lossPercent(sent, recv int) int64 {
if sent <= 0 {
return 0
}
if recv >= sent {
return 0
}
return int64(100 * (sent - recv) / sent)
}