aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.example.yaml63
-rw-r--r--go.mod5
-rw-r--r--go.sum17
-rw-r--r--internal/config/config.go19
-rw-r--r--internal/monitor/dns.go241
-rw-r--r--internal/monitor/graphql.go253
-rw-r--r--internal/monitor/icmp.go150
-rw-r--r--internal/monitor/monitor.go8
-rw-r--r--internal/server/templates/index.html7
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.
#
diff --git a/go.mod b/go.mod
index 3507605..1022bbd 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 4e9294b..bca9c21 100644
--- a/go.sum
+++ b/go.sum
@@ -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>