aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-19 16:53:39 -0800
committerFuwn <[email protected]>2026-01-19 16:53:39 -0800
commitb83baaae6735eccca31a94e630fdeb67919733a0 (patch)
tree625deb2572ed2e8cefa607e88c764c7c21344802
parentfeat: Add reset_on_next_check flag to wipe monitor history (diff)
downloadkaze-b83baaae6735eccca31a94e630fdeb67919733a0.tar.xz
kaze-b83baaae6735eccca31a94e630fdeb67919733a0.zip
feat: Add group defaults, content checking, SSL tracking for Gemini, hide/round options
-rw-r--r--config.example.yaml40
-rw-r--r--internal/config/config.go73
-rw-r--r--internal/monitor/gemini.go52
-rw-r--r--internal/monitor/http.go88
-rw-r--r--internal/monitor/monitor.go27
-rw-r--r--internal/monitor/tcp.go32
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{