aboutsummaryrefslogtreecommitdiff
path: root/internal/monitor
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-19 04:32:18 -0800
committerFuwn <[email protected]>2026-01-19 04:32:18 -0800
commit79e0fb6bc04737137afa6dd4f082c35aae9aab21 (patch)
tree4ad863c31d85a594a52533a0cf8aa10fc8cc4d47 /internal/monitor
parentfeat: Add retry option for monitor checks (diff)
downloadkaze-79e0fb6bc04737137afa6dd4f082c35aae9aab21.tar.xz
kaze-79e0fb6bc04737137afa6dd4f082c35aae9aab21.zip
feat: Add Gemini protocol support
Diffstat (limited to 'internal/monitor')
-rw-r--r--internal/monitor/gemini.go217
-rw-r--r--internal/monitor/monitor.go2
2 files changed, 219 insertions, 0 deletions
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}
}