package monitor import ( "context" "fmt" "net" "net/http" "strconv" "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/miekg/dns" probing "github.com/prometheus-community/pro-bing" ) type CheckResult struct { SiteID int Status string // "UP", "DOWN", "SSL EXP" StatusCode int LatencyNs int64 HasSSL bool CertExpiry time.Time ErrorReason string } func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult { private := len(allowPrivate) > 0 && allowPrivate[0] if site.Type != "http" && site.Type != "dns" && !private { host := site.Hostname if host == "" { host = site.URL } if host != "" { if ips, err := net.LookupIP(host); err == nil { for _, ip := range ips { if isPrivateIP(ip) { return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"} } } } } } switch site.Type { case "http": return runHTTPCheck(site, strict, insecure, globalInsecure) case "ping": return runPingCheck(site) case "port": return runPortCheck(site) case "dns": return runDNSCheck(site) default: return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type} } } func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { method := site.Method if method == "" { method = "GET" } timeout := siteTimeout(site) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()} } client := strict if globalInsecure || site.IgnoreTLS { client = insecure } start := time.Now() resp, err := client.Do(req) latency := time.Since(start) result := CheckResult{ SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds(), } if err != nil { result.Status = "DOWN" result.ErrorReason = truncateError(err.Error(), 256) return result } defer resp.Body.Close() result.StatusCode = resp.StatusCode if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { result.Status = "DOWN" expected := site.AcceptedCodes if expected == "" { expected = "200-299" } result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected) } if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { result.HasSSL = true cert := resp.TLS.PeerCertificates[0] result.CertExpiry = cert.NotAfter if time.Now().After(cert.NotAfter) { result.Status = "SSL EXP" result.ErrorReason = "SSL certificate expired" } } return result } func runPingCheck(site models.Site) CheckResult { host := site.Hostname if host == "" { host = site.URL } pinger, err := probing.NewPinger(host) if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()} } pinger.Count = 1 pinger.Timeout = siteTimeout(site) pinger.SetPrivileged(false) start := time.Now() err = pinger.Run() latency := time.Since(start) if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()} } if pinger.Statistics().PacketsRecv == 0 { return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"} } stats := pinger.Statistics() return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()} } func runPortCheck(site models.Site) CheckResult { host := site.Hostname if host == "" { host = site.URL } addr := net.JoinHostPort(host, strconv.Itoa(site.Port)) timeout := siteTimeout(site) start := time.Now() conn, err := net.DialTimeout("tcp", addr, timeout) latency := time.Since(start) if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), 256)} } _ = conn.Close() return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} } func runDNSCheck(site models.Site) CheckResult { host := site.Hostname if host == "" { host = site.URL } server := site.DNSServer if server == "" { server = "1.1.1.1" } if _, _, err := net.SplitHostPort(server); err != nil { server = net.JoinHostPort(server, "53") } qtype := dns.TypeA switch site.DNSResolveType { case "AAAA": qtype = dns.TypeAAAA case "MX": qtype = dns.TypeMX case "CNAME": qtype = dns.TypeCNAME case "TXT": qtype = dns.TypeTXT case "NS": qtype = dns.TypeNS case "SOA": qtype = dns.TypeSOA case "SRV": qtype = dns.TypeSRV case "PTR": qtype = dns.TypePTR } m := new(dns.Msg) m.SetQuestion(dns.Fqdn(host), qtype) c := new(dns.Client) c.Timeout = siteTimeout(site) start := time.Now() r, _, err := c.Exchange(m, server) latency := time.Since(start) if err != nil { return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()} } if r.Rcode != dns.RcodeSuccess { return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]} } return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} } func siteTimeout(site models.Site) time.Duration { if site.Timeout > 0 { return time.Duration(site.Timeout) * time.Second } return 5 * time.Second } func isCodeAccepted(code int, accepted string) bool { if accepted == "" { return code >= 200 && code < 300 } for _, part := range strings.Split(accepted, ",") { part = strings.TrimSpace(part) if strings.Contains(part, "-") { bounds := strings.SplitN(part, "-", 2) lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) if err1 == nil && err2 == nil && code >= lo && code <= hi { return true } } else { if v, err := strconv.Atoi(part); err == nil && code == v { return true } } } return false } func truncateError(s string, max int) string { if len(s) <= max { return s } return s[:max-3] + "..." }