diff options
| author | Fuwn <[email protected]> | 2026-01-19 16:53:39 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-19 16:53:39 -0800 |
| commit | b83baaae6735eccca31a94e630fdeb67919733a0 (patch) | |
| tree | 625deb2572ed2e8cefa607e88c764c7c21344802 | |
| parent | feat: Add reset_on_next_check flag to wipe monitor history (diff) | |
| download | kaze-b83baaae6735eccca31a94e630fdeb67919733a0.tar.xz kaze-b83baaae6735eccca31a94e630fdeb67919733a0.zip | |
feat: Add group defaults, content checking, SSL tracking for Gemini, hide/round options
| -rw-r--r-- | config.example.yaml | 40 | ||||
| -rw-r--r-- | internal/config/config.go | 73 | ||||
| -rw-r--r-- | internal/monitor/gemini.go | 52 | ||||
| -rw-r--r-- | internal/monitor/http.go | 88 | ||||
| -rw-r--r-- | internal/monitor/monitor.go | 27 | ||||
| -rw-r--r-- | internal/monitor/tcp.go | 32 |
6 files changed, 229 insertions, 83 deletions
diff --git a/config.example.yaml b/config.example.yaml index c3876a7..71c7e0c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,16 +56,25 @@ groups: - name: "Web Services" # default_collapsed: false # Start collapsed (false = expanded by default) # show_group_uptime: true # Show aggregate uptime percentage (true by default) + # Group-level defaults (optional - apply to all monitors in this group unless overridden) + # defaults: + # interval: 30s + # timeout: 10s + # retries: 2 + # verify_ssl: true monitors: - name: "Website" type: https target: "https://example.com" interval: 30s timeout: 10s - retries: 2 # Retry 2 times before marking as down (default: 0) - # reset_on_next_check: true # Wipe all historical data on next check and flip to false + retries: 2 # Retry 2 times before marking as down (default: 0) + # reset_on_next_check: true # Wipe all historical data on next check and flip to false expected_status: 200 + # expected_content: "Welcome" # Check if response body contains this text verify_ssl: true + # hide_ssl_days: false # Hide SSL certificate days left from display + # round_response_time: false # Round response time to nearest second - name: "API" type: https @@ -73,6 +82,7 @@ groups: interval: 30s timeout: 10s expected_status: 200 + expected_content: '"status":"ok"' # Verify JSON response contains expected data method: GET # headers: # Authorization: "Bearer token" @@ -98,7 +108,8 @@ groups: target: "gemini://example.com" # or example.com:1965 interval: 30s timeout: 10s - verify_ssl: true + verify_ssl: false # Often false for self-signed certs in Geminispace + # hide_ssl_days: false # Gemini also tracks TLS certificate expiration # Incidents and maintenance (optional) incidents: @@ -131,27 +142,40 @@ incidents: # Monitor Configuration Reference # ================================ # +# Group-level defaults (optional): +# defaults: +# interval: duration - Default check interval for all monitors in group +# timeout: duration - Default timeout for all monitors in group +# retries: int - Default retry attempts for all monitors in group +# verify_ssl: bool - Default SSL verification for all monitors in group +# Note: Individual monitors can override these defaults +# # 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 -# interval: duration - Check interval (default: 30s) -# timeout: duration - Request timeout (default: 10s) -# retries: int - Number of retry attempts before marking as down (default: 0) +# 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) # Retries are attempted with a 500ms delay between attempts # reset_on_next_check: bool - When true, wipes all historical data for this monitor on next check # Automatically flips to false after reset completes +# hide_ssl_days: bool - Hide SSL/TLS certificate days left from display (default: false) +# round_response_time: bool - Round response time to nearest second (default: false) # # HTTP/HTTPS specific fields: # expected_status: int - Expected HTTP status code (default: 200) +# expected_content: string - Text that must be present in response body # method: string - HTTP method (default: GET) # headers: map[string]string - Custom headers to send # body: string - Request body for POST/PUT/PATCH -# verify_ssl: bool - Verify SSL certificate (default: true) +# verify_ssl: bool - Verify SSL certificate (default: true, or group default) # # Gemini specific fields: -# verify_ssl: bool - Verify TLS certificate (default: true) +# verify_ssl: bool - Verify TLS certificate (default: true, or group default) +# Often set to false for self-signed certs in Geminispace # target format: gemini://host or host:port (default port: 1965) +# Note: Gemini monitors track TLS certificate expiration like HTTPS # # TCP specific fields: # (none - just needs host:port target) diff --git a/internal/config/config.go b/internal/config/config.go index 8839906..13391e2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,22 +59,35 @@ type GroupConfig struct { Monitors []MonitorConfig `yaml:"monitors"` DefaultCollapsed *bool `yaml:"default_collapsed"` // nil = false (expanded by default) ShowGroupUptime *bool `yaml:"show_group_uptime"` // nil = true (show by default) + // Group-level defaults that apply to all monitors in the group (can be overridden per monitor) + Defaults *MonitorDefaults `yaml:"defaults,omitempty"` +} + +// MonitorDefaults contains default values that can be set at group level +type MonitorDefaults struct { + Interval *Duration `yaml:"interval,omitempty"` + Timeout *Duration `yaml:"timeout,omitempty"` + Retries *int `yaml:"retries,omitempty"` + VerifySSL *bool `yaml:"verify_ssl,omitempty"` } // MonitorConfig represents a single monitor type MonitorConfig struct { - Name string `yaml:"name"` - Type string `yaml:"type"` // http, https, tcp, gemini - Target string `yaml:"target"` - Interval Duration `yaml:"interval"` - Timeout Duration `yaml:"timeout"` - Retries int `yaml:"retries,omitempty"` // Number of retry attempts before marking as down - ResetOnNextCheck bool `yaml:"reset_on_next_check,omitempty"` // Wipe monitor data on next check and flip to false - ExpectedStatus int `yaml:"expected_status,omitempty"` - VerifySSL *bool `yaml:"verify_ssl,omitempty"` - Method string `yaml:"method,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - Body string `yaml:"body,omitempty"` + Name string `yaml:"name"` + Type string `yaml:"type"` // http, https, tcp, gemini + Target string `yaml:"target"` + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` + Retries int `yaml:"retries,omitempty"` // Number of retry attempts before marking as down + ResetOnNextCheck bool `yaml:"reset_on_next_check,omitempty"` // Wipe monitor data on next check and flip to false + ExpectedStatus int `yaml:"expected_status,omitempty"` + ExpectedContent string `yaml:"expected_content,omitempty"` // Expected text in response body + VerifySSL *bool `yaml:"verify_ssl,omitempty"` + HideSSLDays bool `yaml:"hide_ssl_days,omitempty"` // Hide SSL days left from display + RoundResponseTime bool `yaml:"round_response_time,omitempty"` // Round response time to nearest second + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body string `yaml:"body,omitempty"` } // IncidentConfig represents an incident or maintenance @@ -194,11 +207,25 @@ func (c *Config) applyDefaults() { for j := range c.Groups[i].Monitors { m := &c.Groups[i].Monitors[j] + + // Apply group-level defaults first, then monitor-level overrides if m.Interval.Duration == 0 { - m.Interval.Duration = 30 * time.Second + if grp.Defaults != nil && grp.Defaults.Interval != nil { + m.Interval = *grp.Defaults.Interval + } else { + m.Interval.Duration = 30 * time.Second + } } if m.Timeout.Duration == 0 { - m.Timeout.Duration = 10 * time.Second + if grp.Defaults != nil && grp.Defaults.Timeout != nil { + m.Timeout = *grp.Defaults.Timeout + } else { + m.Timeout.Duration = 10 * time.Second + } + } + // Apply group-level retries default + if m.Retries == 0 && grp.Defaults != nil && grp.Defaults.Retries != nil { + m.Retries = *grp.Defaults.Retries } // Retries default to 0 (no retries) if not specified if m.Retries < 0 { @@ -212,14 +239,24 @@ func (c *Config) applyDefaults() { m.Method = "GET" } if m.VerifySSL == nil { - defaultVerify := true - m.VerifySSL = &defaultVerify + // Apply group default if available + if grp.Defaults != nil && grp.Defaults.VerifySSL != nil { + m.VerifySSL = grp.Defaults.VerifySSL + } else { + defaultVerify := true + m.VerifySSL = &defaultVerify + } } } if m.Type == "gemini" { if m.VerifySSL == nil { - defaultVerify := true - m.VerifySSL = &defaultVerify + // Apply group default if available + if grp.Defaults != nil && grp.Defaults.VerifySSL != nil { + m.VerifySSL = grp.Defaults.VerifySSL + } else { + defaultVerify := true + m.VerifySSL = &defaultVerify + } } } } diff --git a/internal/monitor/gemini.go b/internal/monitor/gemini.go index d92499b..fb75b7d 100644 --- a/internal/monitor/gemini.go +++ b/internal/monitor/gemini.go @@ -14,12 +14,14 @@ import ( // GeminiMonitor monitors Gemini protocol endpoints type GeminiMonitor struct { - name string - target string - interval time.Duration - timeout time.Duration - retries int - verifySSL bool + name string + target string + interval time.Duration + timeout time.Duration + retries int + verifySSL bool + hideSSLDays bool + roundResponseTime bool } // NewGeminiMonitor creates a new Gemini monitor @@ -50,12 +52,14 @@ func NewGeminiMonitor(cfg config.MonitorConfig) (*GeminiMonitor, error) { } return &GeminiMonitor{ - name: cfg.Name, - target: target, - interval: cfg.Interval.Duration, - timeout: cfg.Timeout.Duration, - retries: cfg.Retries, - verifySSL: verifySSL, + name: cfg.Name, + target: target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + verifySSL: verifySSL, + hideSSLDays: cfg.HideSSLDays, + roundResponseTime: cfg.RoundResponseTime, }, nil } @@ -84,6 +88,16 @@ func (m *GeminiMonitor) Retries() int { return m.retries } +// HideSSLDays returns whether to hide SSL days from display +func (m *GeminiMonitor) HideSSLDays() bool { + return m.hideSSLDays +} + +// RoundResponseTime returns whether to round response time +func (m *GeminiMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + // Check performs the Gemini protocol check func (m *GeminiMonitor) Check(ctx context.Context) *Result { result := &Result{ @@ -119,14 +133,12 @@ func (m *GeminiMonitor) Check(ctx context.Context) *Result { } defer conn.Close() - // Check SSL certificate - if m.verifySSL { - connState := conn.ConnectionState() - if len(connState.PeerCertificates) > 0 { - cert := connState.PeerCertificates[0] - result.SSLExpiry = &cert.NotAfter - result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) - } + // Check SSL certificate (always track, even if not verifying) + connState := conn.ConnectionState() + if len(connState.PeerCertificates) > 0 { + cert := connState.PeerCertificates[0] + result.SSLExpiry = &cert.NotAfter + result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) } // Set deadline for the entire operation diff --git a/internal/monitor/http.go b/internal/monitor/http.go index 5587a5f..b8a0177 100644 --- a/internal/monitor/http.go +++ b/internal/monitor/http.go @@ -15,18 +15,21 @@ import ( // HTTPMonitor monitors HTTP and HTTPS endpoints type HTTPMonitor struct { - name string - monitorType string - target string - interval time.Duration - timeout time.Duration - retries int - method string - headers map[string]string - body string - expectedStatus int - verifySSL bool - client *http.Client + name string + monitorType string + target string + interval time.Duration + timeout time.Duration + retries int + method string + headers map[string]string + body string + expectedStatus int + expectedContent string + verifySSL bool + hideSSLDays bool + roundResponseTime bool + client *http.Client } // NewHTTPMonitor creates a new HTTP/HTTPS monitor @@ -77,18 +80,21 @@ func NewHTTPMonitor(cfg config.MonitorConfig) (*HTTPMonitor, error) { } return &HTTPMonitor{ - name: cfg.Name, - monitorType: cfg.Type, - target: target, - interval: cfg.Interval.Duration, - timeout: cfg.Timeout.Duration, - retries: cfg.Retries, - method: cfg.Method, - headers: cfg.Headers, - body: cfg.Body, - expectedStatus: cfg.ExpectedStatus, - verifySSL: verifySSL, - client: client, + name: cfg.Name, + monitorType: cfg.Type, + target: target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + method: cfg.Method, + headers: cfg.Headers, + body: cfg.Body, + expectedStatus: cfg.ExpectedStatus, + expectedContent: cfg.ExpectedContent, + verifySSL: verifySSL, + hideSSLDays: cfg.HideSSLDays, + roundResponseTime: cfg.RoundResponseTime, + client: client, }, nil } @@ -117,6 +123,16 @@ func (m *HTTPMonitor) Retries() int { return m.retries } +// HideSSLDays returns whether to hide SSL days from display +func (m *HTTPMonitor) HideSSLDays() bool { + return m.hideSSLDays +} + +// RoundResponseTime returns whether to round response time +func (m *HTTPMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + // Check performs the HTTP/HTTPS check func (m *HTTPMonitor) Check(ctx context.Context) *Result { result := &Result{ @@ -155,8 +171,20 @@ func (m *HTTPMonitor) Check(ctx context.Context) *Result { } defer resp.Body.Close() - // Discard body to allow connection reuse - io.Copy(io.Discard, resp.Body) + // Read body if we need to check content, otherwise discard + var bodyContent string + if m.expectedContent != "" { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to read response body: %w", err) + return result + } + bodyContent = string(bodyBytes) + } else { + // Discard body to allow connection reuse + io.Copy(io.Discard, resp.Body) + } result.StatusCode = resp.StatusCode @@ -179,6 +207,14 @@ func (m *HTTPMonitor) Check(ctx context.Context) *Result { result.Error = fmt.Errorf("bad status code: %d", resp.StatusCode) } + // Check expected content if specified + if m.expectedContent != "" && result.Status == StatusUp { + if !strings.Contains(bodyContent, m.expectedContent) { + result.Status = StatusDown + result.Error = fmt.Errorf("expected content not found in response") + } + } + // Check for slow response (degraded if > 2 seconds) if result.Status == StatusUp && result.ResponseTime > 2*time.Second { result.Status = StatusDegraded diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index b0c2f4f..cbf01b3 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) + // Type returns the monitor type (http, https, tcp, gemini) Type() string // Target returns the monitor target (URL or host:port) @@ -46,6 +46,12 @@ type Monitor interface { // Retries returns the number of retry attempts Retries() int + // HideSSLDays returns whether to hide SSL days from display + HideSSLDays() bool + + // RoundResponseTime returns whether to round response time + RoundResponseTime() bool + // Check performs the monitoring check and returns the result Check(ctx context.Context) *Result } @@ -89,3 +95,22 @@ func (r *Result) ToCheckResult() *storage.CheckResult { } return cr } + +// ToCheckResultWithOptions converts a monitor Result to a storage CheckResult with display options applied +func (r *Result) ToCheckResultWithOptions(mon Monitor) *storage.CheckResult { + cr := r.ToCheckResult() + + // Apply rounding if enabled + if mon.RoundResponseTime() { + // Round to nearest second (1000ms) + cr.ResponseTime = ((cr.ResponseTime + 500) / 1000) * 1000 + } + + // Hide SSL days if enabled + if mon.HideSSLDays() { + cr.SSLDaysLeft = 0 + cr.SSLExpiry = nil + } + + return cr +} diff --git a/internal/monitor/tcp.go b/internal/monitor/tcp.go index d315545..d14a5f1 100644 --- a/internal/monitor/tcp.go +++ b/internal/monitor/tcp.go @@ -11,11 +11,12 @@ import ( // TCPMonitor monitors TCP endpoints type TCPMonitor struct { - name string - target string - interval time.Duration - timeout time.Duration - retries int + name string + target string + interval time.Duration + timeout time.Duration + retries int + roundResponseTime bool } // NewTCPMonitor creates a new TCP monitor @@ -27,11 +28,12 @@ func NewTCPMonitor(cfg config.MonitorConfig) (*TCPMonitor, error) { } return &TCPMonitor{ - name: cfg.Name, - target: cfg.Target, - interval: cfg.Interval.Duration, - timeout: cfg.Timeout.Duration, - retries: cfg.Retries, + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + retries: cfg.Retries, + roundResponseTime: cfg.RoundResponseTime, }, nil } @@ -60,6 +62,16 @@ func (m *TCPMonitor) Retries() int { return m.retries } +// HideSSLDays returns whether to hide SSL days from display +func (m *TCPMonitor) HideSSLDays() bool { + return false // TCP doesn't use SSL +} + +// RoundResponseTime returns whether to round response time +func (m *TCPMonitor) RoundResponseTime() bool { + return m.roundResponseTime +} + // Check performs the TCP connection check func (m *TCPMonitor) Check(ctx context.Context) *Result { result := &Result{ |