diff options
| -rw-r--r-- | config.example.yaml | 63 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 17 | ||||
| -rw-r--r-- | internal/config/config.go | 19 | ||||
| -rw-r--r-- | internal/monitor/dns.go | 241 | ||||
| -rw-r--r-- | internal/monitor/graphql.go | 253 | ||||
| -rw-r--r-- | internal/monitor/icmp.go | 150 | ||||
| -rw-r--r-- | internal/monitor/monitor.go | 8 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 7 |
9 files changed, 754 insertions, 9 deletions
diff --git a/config.example.yaml b/config.example.yaml index 18e9dbc..383c0b4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -115,6 +115,44 @@ groups: verify_ssl: false # Often false for self-signed certs in Geminispace # hide_ssl_days: false # Gemini also tracks TLS certificate expiration + - name: "Advanced Monitoring" + monitors: + - name: "Server Ping" + type: icmp + target: "8.8.8.8" # IP address or hostname + interval: 30s + timeout: 5s + # ping_count: 4 # Number of ICMP packets to send (default: 4) + + - name: "DNS Resolution" + type: dns + target: "example.com" # Domain to resolve + interval: 60s + timeout: 5s + # record_type: "A" # DNS record type: A, AAAA, CNAME, MX, TXT (default: A) + # dns_server: "8.8.8.8:53" # Custom DNS server (default: system resolver) + # expected_ips: # Expected IP addresses (optional) + # - "93.184.216.34" + # expected_cname: "example.com." # Expected CNAME record (for CNAME queries) + + - name: "GraphQL API" + type: graphql + target: "https://api.example.com/graphql" + interval: 30s + timeout: 10s + graphql_query: | + query HealthCheck { + health { + status + } + } + # graphql_variables: # Query variables (optional) + # limit: "10" + expected_status: 200 + # expected_content: '"status":"ok"' # Check response contains text + # headers: + # Authorization: "Bearer token" + # Incidents and maintenance (optional) incidents: # Scheduled maintenance example @@ -157,8 +195,8 @@ incidents: # # Common fields for all monitor types: # name: string (required) - Display name for the monitor -# type: string (required) - Monitor type: http, https, tcp, or gemini -# target: string (required) - URL or host:port to monitor +# type: string (required) - Monitor type: http, https, tcp, gemini, icmp, dns, or graphql +# target: string (required) - URL, host:port, IP address, or domain to monitor # interval: duration - Check interval (default: 30s, or group default) # timeout: duration - Request timeout (default: 10s, or group default) # retries: int - Number of retry attempts before marking as down (default: 0, or group default) @@ -189,6 +227,27 @@ incidents: # TCP specific fields: # (none - just needs host:port target) # +# ICMP specific fields: +# target: string - IP address or hostname to ping +# ping_count: int - Number of ICMP packets to send (default: 4) +# Note: May require elevated privileges on some systems +# +# DNS specific fields: +# target: string - Domain name to resolve +# record_type: string - DNS record type: A, AAAA, CNAME, MX, TXT (default: A) +# dns_server: string - Custom DNS server host:port (default: system resolver) +# expected_ips: []string - Expected IP addresses to validate (optional) +# expected_cname: string - Expected CNAME record to validate (optional) +# +# GraphQL specific fields: +# target: string (required) - GraphQL endpoint URL +# graphql_query: string (required) - GraphQL query to execute +# graphql_variables: map - Query variables (optional) +# expected_status: int - Expected HTTP status code (default: 200) +# expected_content: string - Text that must be present in response (optional) +# headers: map[string]string - Custom headers (e.g., Authorization) +# verify_ssl: bool - Verify SSL certificate (default: true) +# # Duration format: # Use Go duration strings: 30s, 1m, 5m, 1h, etc. # @@ -10,12 +10,15 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-ping/ping v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -2,8 +2,11 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-ping/ping v1.2.0 h1:vsJ8slZBZAXNCK4dPcI2PEE9eM9n9RbXbGouVQ/Y4yQ= +github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -18,11 +21,25 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 h1:b0LrWgu8+q7z4J+0Y3Umo5q1dL7NXBkKBWkaVkAq17E= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/config.go b/internal/config/config.go index 7ed28dd..71a9b99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,6 +93,16 @@ type MonitorConfig struct { UserAgent string `yaml:"user_agent,omitempty"` // Custom User-Agent header (default: "Kaze-Monitor/1.0") Headers map[string]string `yaml:"headers,omitempty"` Body string `yaml:"body,omitempty"` + // ICMP specific fields + PingCount int `yaml:"ping_count,omitempty"` // Number of ICMP packets to send (default: 4) + // DNS specific fields + DNSServer string `yaml:"dns_server,omitempty"` // DNS server to query (default: system resolver) + ExpectedIPs []string `yaml:"expected_ips,omitempty"` // Expected IP addresses for DNS resolution + ExpectedCNAME string `yaml:"expected_cname,omitempty"` // Expected CNAME record + RecordType string `yaml:"record_type,omitempty"` // DNS record type (A, AAAA, CNAME, MX, TXT, etc.) + // GraphQL specific fields + GraphQLQuery string `yaml:"graphql_query,omitempty"` // GraphQL query to execute + GraphQLVariables map[string]string `yaml:"graphql_variables,omitempty"` // GraphQL query variables } // IncidentConfig represents an incident or maintenance @@ -300,10 +310,15 @@ func (c *Config) validate() error { } switch monitor.Type { - case "http", "https", "tcp", "gemini": + case "http", "https", "tcp", "gemini", "icmp", "dns", "graphql": // Valid types default: - return fmt.Errorf("monitor %q has invalid type %q (must be http, https, tcp, or gemini)", monitor.Name, monitor.Type) + return fmt.Errorf("monitor %q has invalid type %q (must be http, https, tcp, gemini, icmp, dns, or graphql)", monitor.Name, monitor.Type) + } + + // Type-specific validation + if monitor.Type == "graphql" && monitor.GraphQLQuery == "" { + return fmt.Errorf("monitor %q is type 'graphql' but missing required 'graphql_query' field", monitor.Name) } } } diff --git a/internal/monitor/dns.go b/internal/monitor/dns.go new file mode 100644 index 0000000..4f4f099 --- /dev/null +++ b/internal/monitor/dns.go @@ -0,0 +1,241 @@ +package monitor + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// DNSMonitor monitors DNS resolution +type DNSMonitor struct { + name string + target string // Domain to resolve + interval time.Duration + timeout time.Duration + retries int + roundResponseTime bool + roundUptime bool + dnsServer string // Optional DNS server + expectedIPs []string // Expected IP addresses + expectedCNAME string // Expected CNAME + recordType string // DNS record type (A, AAAA, CNAME, MX, TXT, etc.) +} + +// NewDNSMonitor creates a new DNS monitor +func NewDNSMonitor(cfg config.MonitorConfig) (*DNSMonitor, error) { + // Default to A record if not specified + recordType := "A" + if cfg.RecordType != "" { + recordType = strings.ToUpper(cfg.RecordType) + } + + return &DNSMonitor{ + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + roundResponseTime: cfg.RoundResponseTime, + roundUptime: cfg.RoundUptime, + dnsServer: cfg.DNSServer, + expectedIPs: cfg.ExpectedIPs, + expectedCNAME: cfg.ExpectedCNAME, + recordType: recordType, + }, nil +} + +// Name returns the monitor's name +func (m *DNSMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *DNSMonitor) Type() string { + return "dns" +} + +// Target returns the monitor target +func (m *DNSMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *DNSMonitor) Interval() time.Duration { + return m.interval +} + +// Retries returns the number of retry attempts +func (m *DNSMonitor) Retries() int { + return m.retries +} + +// HideSSLDays returns whether to hide SSL days from display +func (m *DNSMonitor) HideSSLDays() bool { + return false // DNS doesn't use SSL +} + +// RoundResponseTime returns whether to round response time +func (m *DNSMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + +// RoundUptime returns whether to round uptime percentage +func (m *DNSMonitor) RoundUptime() bool { + return m.roundUptime +} + +// Check performs the DNS resolution check +func (m *DNSMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Create resolver + resolver := &net.Resolver{} + if m.dnsServer != "" { + // Use custom DNS server + resolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: m.timeout} + return d.DialContext(ctx, "udp", m.dnsServer) + }, + } + } + + // Create timeout context + timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout) + defer cancel() + + start := time.Now() + + switch m.recordType { + case "A", "AAAA": + ips, err := resolver.LookupIP(timeoutCtx, "ip", m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("DNS lookup failed: %w", err) + return result + } + + if len(ips) == 0 { + result.Status = StatusDown + result.Error = fmt.Errorf("no IP addresses returned") + return result + } + + // Check if expected IPs match + if len(m.expectedIPs) > 0 { + found := false + for _, expectedIP := range m.expectedIPs { + for _, ip := range ips { + if ip.String() == expectedIP { + found = true + break + } + } + if found { + break + } + } + if !found { + result.Status = StatusDegraded + result.Error = fmt.Errorf("resolved IPs don't match expected: got %v, expected %v", ips, m.expectedIPs) + return result + } + } + + result.Status = StatusUp + + case "CNAME": + cname, err := resolver.LookupCNAME(timeoutCtx, m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("CNAME lookup failed: %w", err) + return result + } + + // Check if expected CNAME matches + if m.expectedCNAME != "" && cname != m.expectedCNAME { + result.Status = StatusDegraded + result.Error = fmt.Errorf("CNAME mismatch: got %s, expected %s", cname, m.expectedCNAME) + return result + } + + result.Status = StatusUp + + case "MX": + mxRecords, err := resolver.LookupMX(timeoutCtx, m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("MX lookup failed: %w", err) + return result + } + + if len(mxRecords) == 0 { + result.Status = StatusDown + result.Error = fmt.Errorf("no MX records found") + return result + } + + result.Status = StatusUp + + case "TXT": + txtRecords, err := resolver.LookupTXT(timeoutCtx, m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("TXT lookup failed: %w", err) + return result + } + + if len(txtRecords) == 0 { + result.Status = StatusDown + result.Error = fmt.Errorf("no TXT records found") + return result + } + + result.Status = StatusUp + + default: + // Fallback to generic IP lookup + ips, err := resolver.LookupIP(timeoutCtx, "ip", m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("DNS lookup failed: %w", err) + return result + } + + if len(ips) == 0 { + result.Status = StatusDown + result.Error = fmt.Errorf("no IP addresses returned") + return result + } + + result.Status = StatusUp + } + + // Check for slow DNS resolution (degraded if > 1 second) + if result.Status == StatusUp && result.ResponseTime > 1*time.Second { + result.Status = StatusDegraded + if result.Error == nil { + result.Error = fmt.Errorf("slow DNS resolution: %v", result.ResponseTime) + } + } + + return result +} diff --git a/internal/monitor/graphql.go b/internal/monitor/graphql.go new file mode 100644 index 0000000..5b1fc91 --- /dev/null +++ b/internal/monitor/graphql.go @@ -0,0 +1,253 @@ +package monitor + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// GraphQLMonitor monitors GraphQL endpoints +type GraphQLMonitor struct { + name string + target string + interval time.Duration + timeout time.Duration + retries int + expectedStatus int + expectedContent string + verifySSL bool + hideSSLDays bool + roundResponseTime bool + roundUptime bool + query string + variables map[string]string + headers map[string]string + userAgent string + client *http.Client +} + +// GraphQLRequest represents a GraphQL request payload +type GraphQLRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +// GraphQLResponse represents a GraphQL response +type GraphQLResponse struct { + Data interface{} `json:"data,omitempty"` + Errors []GraphQLError `json:"errors,omitempty"` +} + +// GraphQLError represents a GraphQL error +type GraphQLError struct { + Message string `json:"message"` +} + +// NewGraphQLMonitor creates a new GraphQL monitor +func NewGraphQLMonitor(cfg config.MonitorConfig) (*GraphQLMonitor, error) { + if cfg.GraphQLQuery == "" { + return nil, fmt.Errorf("graphql_query is required for GraphQL monitors") + } + + verifySSL := true + if cfg.VerifySSL != nil { + verifySSL = *cfg.VerifySSL + } + + expectedStatus := cfg.ExpectedStatus + if expectedStatus == 0 { + expectedStatus = 200 + } + + userAgent := cfg.UserAgent + if userAgent == "" { + userAgent = "Kaze-Monitor/1.0" + } + + // Create HTTP client with custom transport + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !verifySSL, + }, + } + + client := &http.Client{ + Timeout: cfg.Timeout.Duration, + Transport: transport, + } + + return &GraphQLMonitor{ + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + expectedStatus: expectedStatus, + expectedContent: cfg.ExpectedContent, + verifySSL: verifySSL, + hideSSLDays: cfg.HideSSLDays, + roundResponseTime: cfg.RoundResponseTime, + roundUptime: cfg.RoundUptime, + query: cfg.GraphQLQuery, + variables: cfg.GraphQLVariables, + headers: cfg.Headers, + userAgent: userAgent, + client: client, + }, nil +} + +// Name returns the monitor's name +func (m *GraphQLMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *GraphQLMonitor) Type() string { + return "graphql" +} + +// Target returns the monitor target +func (m *GraphQLMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *GraphQLMonitor) Interval() time.Duration { + return m.interval +} + +// Retries returns the number of retry attempts +func (m *GraphQLMonitor) Retries() int { + return m.retries +} + +// HideSSLDays returns whether to hide SSL days from display +func (m *GraphQLMonitor) HideSSLDays() bool { + return m.hideSSLDays +} + +// RoundResponseTime returns whether to round response time +func (m *GraphQLMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + +// RoundUptime returns whether to round uptime percentage +func (m *GraphQLMonitor) RoundUptime() bool { + return m.roundUptime +} + +// Check performs the GraphQL endpoint check +func (m *GraphQLMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Prepare GraphQL request + variables := make(map[string]interface{}) + for k, v := range m.variables { + variables[k] = v + } + + gqlReq := GraphQLRequest{ + Query: m.query, + Variables: variables, + } + + jsonData, err := json.Marshal(gqlReq) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to marshal GraphQL request: %w", err) + return result + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "POST", m.target, bytes.NewBuffer(jsonData)) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to create request: %w", err) + return result + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", m.userAgent) + for key, value := range m.headers { + req.Header.Set(key, value) + } + + // Perform the request + start := time.Now() + resp, err := m.client.Do(req) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("request failed: %w", err) + return result + } + defer resp.Body.Close() + + result.StatusCode = resp.StatusCode + + // Check if we need to track SSL certificate + if strings.HasPrefix(m.target, "https://") && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + cert := resp.TLS.PeerCertificates[0] + result.SSLExpiry = &cert.NotAfter + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + result.SSLDaysLeft = daysLeft + } + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to read response: %w", err) + return result + } + + // Parse GraphQL response + var gqlResp GraphQLResponse + if err := json.Unmarshal(body, &gqlResp); err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to parse GraphQL response: %w", err) + return result + } + + // Check for GraphQL errors + if len(gqlResp.Errors) > 0 { + errorMessages := make([]string, len(gqlResp.Errors)) + for i, gqlErr := range gqlResp.Errors { + errorMessages[i] = gqlErr.Message + } + result.Status = StatusDown + result.Error = fmt.Errorf("GraphQL errors: %s", strings.Join(errorMessages, "; ")) + return result + } + + // Check HTTP status code + if resp.StatusCode != m.expectedStatus { + result.Status = StatusDown + result.Error = fmt.Errorf("unexpected status code: got %d, expected %d", resp.StatusCode, m.expectedStatus) + return result + } + + // Check for expected content in response + if m.expectedContent != "" && !strings.Contains(string(body), m.expectedContent) { + result.Status = StatusDegraded + result.Error = fmt.Errorf("expected content not found in response") + return result + } + + result.Status = StatusUp + + return result +} diff --git a/internal/monitor/icmp.go b/internal/monitor/icmp.go new file mode 100644 index 0000000..8b1385c --- /dev/null +++ b/internal/monitor/icmp.go @@ -0,0 +1,150 @@ +package monitor + +import ( + "context" + "fmt" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/go-ping/ping" +) + +// ICMPMonitor monitors hosts using ICMP ping +type ICMPMonitor struct { + name string + target string + interval time.Duration + timeout time.Duration + retries int + roundResponseTime bool + roundUptime bool + count int // Number of ping packets to send +} + +// NewICMPMonitor creates a new ICMP monitor +func NewICMPMonitor(cfg config.MonitorConfig) (*ICMPMonitor, error) { + // Default to 4 pings if not specified + count := 4 + if cfg.PingCount > 0 { + count = cfg.PingCount + } + + return &ICMPMonitor{ + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + roundResponseTime: cfg.RoundResponseTime, + roundUptime: cfg.RoundUptime, + count: count, + }, nil +} + +// Name returns the monitor's name +func (m *ICMPMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *ICMPMonitor) Type() string { + return "icmp" +} + +// Target returns the monitor target +func (m *ICMPMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *ICMPMonitor) Interval() time.Duration { + return m.interval +} + +// Retries returns the number of retry attempts +func (m *ICMPMonitor) Retries() int { + return m.retries +} + +// HideSSLDays returns whether to hide SSL days from display +func (m *ICMPMonitor) HideSSLDays() bool { + return false // ICMP doesn't use SSL +} + +// RoundResponseTime returns whether to round response time +func (m *ICMPMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + +// RoundUptime returns whether to round uptime percentage +func (m *ICMPMonitor) RoundUptime() bool { + return m.roundUptime +} + +// Check performs the ICMP ping check +func (m *ICMPMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + pinger, err := ping.NewPinger(m.target) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to create pinger: %w", err) + return result + } + + // Configure pinger + pinger.Count = m.count + pinger.Timeout = m.timeout + pinger.SetPrivileged(false) // Use unprivileged mode (UDP) by default + + // Run with context cancellation support + done := make(chan error, 1) + go func() { + done <- pinger.Run() + }() + + select { + case <-ctx.Done(): + pinger.Stop() + result.Status = StatusDown + result.Error = fmt.Errorf("ping cancelled: %w", ctx.Err()) + return result + case err := <-done: + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("ping failed: %w", err) + return result + } + } + + stats := pinger.Statistics() + + // If no packets were received, mark as down + if stats.PacketsRecv == 0 { + result.Status = StatusDown + result.Error = fmt.Errorf("no packets received (100%% packet loss)") + result.ResponseTime = m.timeout + return result + } + + // Use average RTT as response time + result.ResponseTime = stats.AvgRtt + + // Determine status based on packet loss + packetLoss := float64(stats.PacketsSent-stats.PacketsRecv) / float64(stats.PacketsSent) * 100 + + if packetLoss == 0 { + result.Status = StatusUp + } else if packetLoss < 50 { + result.Status = StatusDegraded + result.Error = fmt.Errorf("%.0f%% packet loss", packetLoss) + } else { + result.Status = StatusDown + result.Error = fmt.Errorf("%.0f%% packet loss", packetLoss) + } + + return result +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index f4a6f66..5ec283a 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -34,7 +34,7 @@ type Monitor interface { // Name returns the monitor's name Name() string - // Type returns the monitor type (http, https, tcp, gemini) + // Type returns the monitor type (http, https, tcp, gemini, icmp, dns, graphql) Type() string // Target returns the monitor target (URL or host:port) @@ -68,6 +68,12 @@ func New(cfg config.MonitorConfig) (Monitor, error) { return NewTCPMonitor(cfg) case "gemini": return NewGeminiMonitor(cfg) + case "icmp": + return NewICMPMonitor(cfg) + case "dns": + return NewDNSMonitor(cfg) + case "graphql": + return NewGraphQLMonitor(cfg) default: return nil, &UnsupportedTypeError{Type: cfg.Type} } diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index f136e51..db4c61a 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -83,6 +83,7 @@ </div> <div class="divide-y divide-neutral-200 dark:divide-neutral-800 group-content" data-group-content="{{$group.Name}}" data-default-collapsed="{{$group.DefaultCollapsed}}"> {{range .Monitors}} + {{$monitor := .}} <div class="p-4 hover:bg-neutral-100/50 dark:hover:bg-neutral-900/50 transition-colors"> <div class="flex items-start justify-between gap-4"> <div class="flex-1 min-w-0"> @@ -115,11 +116,11 @@ </div> <!-- History Bar --> <div class="mt-3 flex gap-px"> - {{range .Ticks}} - <div class="flex-1 h-6 rounded-sm {{tickColor .}}"{{if not $.DisablePingTooltips}} data-tooltip='{{tickTooltipData . $.TickMode $.Timezone}}'{{end}}></div> + {{range $monitor.Ticks}} + <div class="flex-1 h-6 rounded-sm {{tickColor .}}"{{if not $monitor.DisablePingTooltips}} data-tooltip='{{tickTooltipData . $.TickMode $.Timezone}}'{{end}}></div> {{else}} {{range seq $.TickCount}} - <div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800"{{if not $.DisablePingTooltips}} data-tooltip='{"header":"No data"}'{{end}}></div> + <div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800"{{if not $monitor.DisablePingTooltips}} data-tooltip='{"header":"No data"}'{{end}}></div> {{end}} {{end}} </div> |