feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
39
checks/cudp/result.go
Обычный файл
39
checks/cudp/result.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single UDP check. It embeds
|
||||
// checkresult.CheckResult and adds the resolved address so logs /
|
||||
// metrics can show what was probed.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// InfluxFields reports the dial+probe duration in milliseconds — same
|
||||
// convention as chttp / cping / ctcp.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the standard set of tags used by chttp / cping /
|
||||
// ctcp. The "state" tag is the final Result.State set by Perform().
|
||||
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
|
||||
}
|
||||
161
checks/cudp/udp.go
Обычный файл
161
checks/cudp/udp.go
Обычный файл
@@ -0,0 +1,161 @@
|
||||
// Package cudp provides UDP probe / port check functionality for RSMon.
|
||||
//
|
||||
// Semantics: UDP is a connectionless protocol so a successful connect
|
||||
// (net.Dial("udp", ...)) only means the kernel resolved the route to
|
||||
// host:port — the remote may silently drop the packet. The check
|
||||
// therefore:
|
||||
//
|
||||
// 1. Resolves the address and opens a UDP "connection".
|
||||
//
|
||||
// 2. Writes a small probe packet with the dial deadline active.
|
||||
//
|
||||
// 3. Sets a short read deadline and waits for any reply.
|
||||
//
|
||||
// - OK is reported only when a reply is received from the server.
|
||||
// - ERR is reported when the dial itself fails (refused, network
|
||||
// unreachable, no route, ...).
|
||||
// - WARN is reported when no reply is received within the deadline
|
||||
// because that is the most common UDP behavior for a real
|
||||
// service that isn't echoing probes; we still consider it
|
||||
// "monitoring" — the route is reachable — but flag it as worth
|
||||
// investigating.
|
||||
//
|
||||
// Settings consumed from models.CheckSettings:
|
||||
// - port (string): UDP port to probe. Defaults to 53 when empty.
|
||||
// - timeout (int, seconds): per-probe budget. Clamped to >= 1s.
|
||||
// - host (string): optional override of the monitor host.
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
stateWARN = "WARN"
|
||||
|
||||
defaultTimeout = 5 * time.Second
|
||||
minTimeout = 1 * time.Second
|
||||
|
||||
probeSize = 16
|
||||
)
|
||||
|
||||
var probe = []byte("rsmon-udp-probe")
|
||||
|
||||
// Perform executes a single UDP probe check.
|
||||
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 = stateERR
|
||||
r.Error = fmt.Errorf("udp: empty host")
|
||||
return r
|
||||
}
|
||||
|
||||
port := settings.Port
|
||||
if port == "" {
|
||||
port = "53"
|
||||
}
|
||||
|
||||
timeout := time.Duration(settings.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
if timeout < minTimeout {
|
||||
timeout = minTimeout
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout, DualStack: true}
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("udp", addr)
|
||||
if err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = err
|
||||
return r
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close
|
||||
|
||||
r.RemoteAddr = conn.RemoteAddr().String()
|
||||
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("set write deadline: %w", err)
|
||||
return r
|
||||
}
|
||||
if _, err := conn.Write(probe[:minInt(probeSize, len(probe))]); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("write probe: %w", err)
|
||||
return r
|
||||
}
|
||||
|
||||
// If the dial succeeded, give the read a fraction of the budget
|
||||
// so the whole check still fits inside Settings.Timeout.
|
||||
readBudget := timeout
|
||||
if readBudget > 2*time.Second {
|
||||
readBudget = 2 * time.Second
|
||||
}
|
||||
if err := conn.SetReadDeadline(time.Now().Add(readBudget)); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("set read deadline: %w", err)
|
||||
return r
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
_, readErr := conn.Read(buf)
|
||||
r.Duration = time.Since(start)
|
||||
|
||||
switch {
|
||||
case readErr == nil:
|
||||
r.State = stateOK
|
||||
r.Infos = append(r.Infos, fmt.Sprintf("udp %s replied in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0))
|
||||
case isTimeout(readErr):
|
||||
// UDP services rarely echo unrecognized probes back. Reaching
|
||||
// the host without a reply is meaningful, but ambiguous: the
|
||||
// service might be working or firewalled. Surface as WARN.
|
||||
r.State = stateWARN
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf("udp %s reachable but no reply within %s", addr, readBudget))
|
||||
default:
|
||||
// Any other read error (connection reset, ...) still means the
|
||||
// kernel could route the packet; report as ERR so operators
|
||||
// know to investigate the service.
|
||||
r.State = stateERR
|
||||
r.Error = readErr
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
type timeout interface{ Timeout() bool }
|
||||
if t, ok := err.(timeout); ok {
|
||||
return t.Timeout()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// minInt mirrors the standard library min() for ints without taking
|
||||
// a dependency on Go 1.21+. We keep a custom name to avoid clashing
|
||||
// with the built-in and being flagged by the linter.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
170
checks/cudp/udp_test.go
Обычный файл
170
checks/cudp/udp_test.go
Обычный файл
@@ -0,0 +1,170 @@
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
PacketSize int `json:"packet_size,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: %v", err)
|
||||
}
|
||||
return &models.Check{
|
||||
Kind: "udp",
|
||||
Monitor: &models.Monitor{Host: host},
|
||||
Settings: datatypes.JSON(raw),
|
||||
}
|
||||
}
|
||||
|
||||
// startEchoUDP brings up a UDP listener on 127.0.0.1:0 that echoes
|
||||
// the first byte back to the sender. Returns the listener and the
|
||||
// resolved address. Stop it with the returned cleanup.
|
||||
func startEchoUDP(t *testing.T) (cleanup func(), addr string) {
|
||||
t.Helper()
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("listen udp: %v", err)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
|
||||
n, src, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
_, _ = conn.WriteToUDP(buf[:1], src)
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
_ = conn.Close()
|
||||
}, conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
func TestPerformOKWhenServerReplies(t *testing.T) {
|
||||
cleanup, addr := startEchoUDP(t)
|
||||
defer cleanup()
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split: %v", err)
|
||||
}
|
||||
c := newCheck(t, settings{Port: port, Timeout: 2}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK when the server echoes back, got %s (err=%v, warn=%v)", r.State, r.Error, r.Warnings)
|
||||
}
|
||||
if r.RemoteAddr == "" {
|
||||
t.Fatalf("expected RemoteAddr, got %q", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformWarnWhenNoReply(t *testing.T) {
|
||||
// Open a UDP listener that never replies; the probe should
|
||||
// time out and the check should report WARN to signal "reachable,
|
||||
// ambiguous service".
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// Drain the listener so the kernel knows the port is in use but
|
||||
// never reply; just close the read side and let the kernel drop
|
||||
// incoming datagrams.
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
_, _, _ = conn.ReadFromUDP(buf)
|
||||
}
|
||||
}()
|
||||
|
||||
host, port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateWARN {
|
||||
t.Fatalf("expected WARN when the server is silent, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if len(r.Warnings) == 0 {
|
||||
t.Fatalf("expected a warning describing the silence, got %v", r.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUnreachable(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99")
|
||||
r := Perform(c)
|
||||
if r.State == stateOK {
|
||||
t.Fatalf("expected ERR/WARN, got OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformEmptyHost(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "53", Timeout: 1}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR on empty host, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTimeout(t *testing.T) {
|
||||
if isTimeout(nil) {
|
||||
t.Fatalf("isTimeout(nil) should be false")
|
||||
}
|
||||
if !isTimeout(timeoutErr{}) {
|
||||
t.Fatalf("isTimeout(timeoutErr) should be true")
|
||||
}
|
||||
if isTimeout(plainErr{}) {
|
||||
t.Fatalf("isTimeout(plainErr) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (timeoutErr) Error() string { return "i/o timeout" }
|
||||
func (timeoutErr) Timeout() bool { return true }
|
||||
func (timeoutErr) Temporary() bool { return true }
|
||||
|
||||
type plainErr struct{}
|
||||
|
||||
func (plainErr) Error() string { return "boom" }
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
if got := minInt(1, 2); got != 1 {
|
||||
t.Fatalf("minInt(1,2) = %d, want 1", got)
|
||||
}
|
||||
if got := minInt(2, 1); got != 1 {
|
||||
t.Fatalf("minInt(2,1) = %d, want 1", got)
|
||||
}
|
||||
if got := minInt(0, 0); got != 0 {
|
||||
t.Fatalf("minInt(0,0) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user