// 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" "rocketgit.ru/rsmon/worker/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 }