diff options
| author | Fuwn <[email protected]> | 2026-01-19 04:32:18 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-19 04:32:18 -0800 |
| commit | 79e0fb6bc04737137afa6dd4f082c35aae9aab21 (patch) | |
| tree | 4ad863c31d85a594a52533a0cf8aa10fc8cc4d47 | |
| parent | feat: Add retry option for monitor checks (diff) | |
| download | kaze-79e0fb6bc04737137afa6dd4f082c35aae9aab21.tar.xz kaze-79e0fb6bc04737137afa6dd4f082c35aae9aab21.zip | |
feat: Add Gemini protocol support
| -rw-r--r-- | config.example.yaml | 13 | ||||
| -rw-r--r-- | internal/config/config.go | 10 | ||||
| -rw-r--r-- | internal/monitor/gemini.go | 217 | ||||
| -rw-r--r-- | internal/monitor/monitor.go | 2 |
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} } |