aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.example.yaml13
-rw-r--r--internal/config/config.go10
-rw-r--r--internal/monitor/gemini.go217
-rw-r--r--internal/monitor/monitor.go2
4 files changed, 239 insertions, 3 deletions
diff --git a/config.example.yaml b/config.example.yaml
index 70a714b..dd15ac1 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -92,6 +92,13 @@ groups:
interval: 30s
timeout: 5s
+ - name: "Gemini Capsule"
+ type: gemini
+ target: "gemini://example.com" # or example.com:1965
+ interval: 30s
+ timeout: 10s
+ verify_ssl: true
+
# Incidents and maintenance (optional)
incidents:
# Scheduled maintenance example
@@ -125,7 +132,7 @@ incidents:
#
# Common fields for all monitor types:
# name: string (required) - Display name for the monitor
-# type: string (required) - Monitor type: http, https, or tcp
+# 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)
@@ -139,6 +146,10 @@ incidents:
# body: string - Request body for POST/PUT/PATCH
# verify_ssl: bool - Verify SSL certificate (default: true)
#
+# Gemini specific fields:
+# verify_ssl: bool - Verify TLS certificate (default: true)
+# target format: gemini://host or host:port (default port: 1965)
+#
# TCP specific fields:
# (none - just needs host:port target)
#
diff --git a/internal/config/config.go b/internal/config/config.go
index f7f1ad6..1c033e1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -215,6 +215,12 @@ func (c *Config) applyDefaults() {
m.VerifySSL = &defaultVerify
}
}
+ if m.Type == "gemini" {
+ if m.VerifySSL == nil {
+ defaultVerify := true
+ m.VerifySSL = &defaultVerify
+ }
+ }
}
}
}
@@ -247,10 +253,10 @@ func (c *Config) validate() error {
}
switch monitor.Type {
- case "http", "https", "tcp":
+ case "http", "https", "tcp", "gemini":
// Valid types
default:
- return fmt.Errorf("monitor %q has invalid type %q (must be http, https, or tcp)", monitor.Name, monitor.Type)
+ return fmt.Errorf("monitor %q has invalid type %q (must be http, https, tcp, or gemini)", monitor.Name, monitor.Type)
}
}
}
diff --git a/internal/monitor/gemini.go b/internal/monitor/gemini.go
new file mode 100644
index 0000000..d92499b
--- /dev/null
+++ b/internal/monitor/gemini.go
@@ -0,0 +1,217 @@
+package monitor
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/Fuwn/kaze/internal/config"
+)
+
+// GeminiMonitor monitors Gemini protocol endpoints
+type GeminiMonitor struct {
+ name string
+ target string
+ interval time.Duration
+ timeout time.Duration
+ retries int
+ verifySSL bool
+}
+
+// NewGeminiMonitor creates a new Gemini monitor
+func NewGeminiMonitor(cfg config.MonitorConfig) (*GeminiMonitor, error) {
+ // Parse target - should be gemini://host or host:port
+ target := cfg.Target
+
+ // Remove gemini:// prefix if present
+ target = strings.TrimPrefix(target, "gemini://")
+
+ // If no port specified, add default Gemini port 1965
+ if !strings.Contains(target, ":") {
+ target = target + ":1965"
+ }
+
+ // Validate host:port format
+ host, port, err := net.SplitHostPort(target)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Gemini target %q: must be host:port format: %w", cfg.Target, err)
+ }
+
+ // Store as host:port for connection
+ target = net.JoinHostPort(host, port)
+
+ verifySSL := true
+ if cfg.VerifySSL != nil {
+ verifySSL = *cfg.VerifySSL
+ }
+
+ return &GeminiMonitor{
+ name: cfg.Name,
+ target: target,
+ interval: cfg.Interval.Duration,
+ timeout: cfg.Timeout.Duration,
+ retries: cfg.Retries,
+ verifySSL: verifySSL,
+ }, nil
+}
+
+// Name returns the monitor's name
+func (m *GeminiMonitor) Name() string {
+ return m.name
+}
+
+// Type returns the monitor type
+func (m *GeminiMonitor) Type() string {
+ return "gemini"
+}
+
+// Target returns the monitor target
+func (m *GeminiMonitor) Target() string {
+ return m.target
+}
+
+// Interval returns the check interval
+func (m *GeminiMonitor) Interval() time.Duration {
+ return m.interval
+}
+
+// Retries returns the number of retry attempts
+func (m *GeminiMonitor) Retries() int {
+ return m.retries
+}
+
+// Check performs the Gemini protocol check
+func (m *GeminiMonitor) Check(ctx context.Context) *Result {
+ result := &Result{
+ MonitorName: m.name,
+ Timestamp: time.Now(),
+ }
+
+ // Extract hostname for SNI
+ host, _, _ := net.SplitHostPort(m.target)
+
+ // Configure TLS
+ tlsConfig := &tls.Config{
+ ServerName: host,
+ InsecureSkipVerify: !m.verifySSL,
+ MinVersion: tls.VersionTLS12,
+ }
+
+ // Create dialer with timeout
+ dialer := &net.Dialer{
+ Timeout: m.timeout,
+ }
+
+ // Measure connection and request time
+ start := time.Now()
+
+ // Connect with TLS
+ conn, err := tls.DialWithDialer(dialer, "tcp", m.target, tlsConfig)
+ if err != nil {
+ result.Status = StatusDown
+ result.ResponseTime = time.Since(start)
+ result.Error = fmt.Errorf("connection failed: %w", err)
+ return 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)
+ }
+ }
+
+ // Set deadline for the entire operation
+ deadline, ok := ctx.Deadline()
+ if ok {
+ conn.SetDeadline(deadline)
+ } else {
+ conn.SetDeadline(time.Now().Add(m.timeout))
+ }
+
+ // Send Gemini request
+ // Format: gemini://host/path\r\n
+ geminiURL := fmt.Sprintf("gemini://%s/\r\n", host)
+ _, err = conn.Write([]byte(geminiURL))
+ if err != nil {
+ result.Status = StatusDown
+ result.ResponseTime = time.Since(start)
+ result.Error = fmt.Errorf("failed to send request: %w", err)
+ return result
+ }
+
+ // Read response header
+ reader := bufio.NewReader(conn)
+ responseLine, err := reader.ReadString('\n')
+ result.ResponseTime = time.Since(start)
+
+ if err != nil {
+ result.Status = StatusDown
+ result.Error = fmt.Errorf("failed to read response: %w", err)
+ return result
+ }
+
+ // Parse Gemini response
+ // Format: <STATUS><SPACE><META><CR><LF>
+ responseLine = strings.TrimSpace(responseLine)
+ parts := strings.SplitN(responseLine, " ", 2)
+
+ if len(parts) < 1 {
+ result.Status = StatusDown
+ result.Error = fmt.Errorf("invalid response format")
+ return result
+ }
+
+ // Parse status code (first 2 digits)
+ if len(parts[0]) < 2 {
+ result.Status = StatusDown
+ result.Error = fmt.Errorf("invalid status code: %s", parts[0])
+ return result
+ }
+
+ statusCode := parts[0][:2]
+
+ // Gemini status codes:
+ // 1x = INPUT (need user input)
+ // 2x = SUCCESS
+ // 3x = REDIRECT
+ // 4x = TEMPORARY FAILURE
+ // 5x = PERMANENT FAILURE
+ // 6x = CLIENT CERTIFICATE REQUIRED
+
+ switch statusCode[0] {
+ case '2': // Success (20 = success)
+ result.Status = StatusUp
+ case '3': // Redirect - consider as working
+ result.Status = StatusUp
+ case '1': // Input required - server is up but needs input
+ result.Status = StatusUp
+ case '4': // Temporary failure
+ result.Status = StatusDegraded
+ result.Error = fmt.Errorf("temporary failure: %s", responseLine)
+ case '5': // Permanent failure
+ result.Status = StatusDown
+ result.Error = fmt.Errorf("permanent failure: %s", responseLine)
+ case '6': // Client cert required - server is up
+ result.Status = StatusUp
+ default:
+ result.Status = StatusDown
+ result.Error = fmt.Errorf("unknown status code: %s", statusCode)
+ }
+
+ // Check for slow response (degraded if > 2 seconds)
+ if result.Status == StatusUp && result.ResponseTime > 2*time.Second {
+ result.Status = StatusDegraded
+ result.Error = fmt.Errorf("slow response: %v", result.ResponseTime)
+ }
+
+ return result
+}
diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go
index be6ff27..b0c2f4f 100644
--- a/internal/monitor/monitor.go
+++ b/internal/monitor/monitor.go
@@ -57,6 +57,8 @@ func New(cfg config.MonitorConfig) (Monitor, error) {
return NewHTTPMonitor(cfg)
case "tcp":
return NewTCPMonitor(cfg)
+ case "gemini":
+ return NewGeminiMonitor(cfg)
default:
return nil, &UnsupportedTypeError{Type: cfg.Type}
}