From 79e0fb6bc04737137afa6dd4f082c35aae9aab21 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Mon, 19 Jan 2026 04:32:18 -0800 Subject: feat: Add Gemini protocol support --- internal/monitor/gemini.go | 217 ++++++++++++++++++++++++++++++++++++++++++++ internal/monitor/monitor.go | 2 + 2 files changed, 219 insertions(+) create mode 100644 internal/monitor/gemini.go (limited to 'internal/monitor') 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: + 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} } -- cgit v1.2.3