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 { id string name string group string target string interval time.Duration timeout time.Duration retries int verifySSL bool hideSSLDays bool roundResponseTime bool roundUptime 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{ id: cfg.ID(), name: cfg.Name, group: cfg.Group, target: target, interval: cfg.Interval.Duration, timeout: cfg.Timeout.Duration, retries: cfg.Retries, verifySSL: verifySSL, hideSSLDays: cfg.HideSSLDays, roundResponseTime: cfg.RoundResponseTime, roundUptime: cfg.RoundUptime, }, nil } // ID returns the unique identifier for this monitor func (m *GeminiMonitor) ID() string { return m.id } // Name returns the monitor's name func (m *GeminiMonitor) Name() string { return m.name } // Group returns the group this monitor belongs to func (m *GeminiMonitor) Group() string { return m.group } // 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 } // 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 } // RoundUptime returns whether to round uptime percentage func (m *GeminiMonitor) RoundUptime() bool { return m.roundUptime } // Check performs the Gemini protocol check func (m *GeminiMonitor) Check(ctx context.Context) *Result { result := &Result{ MonitorName: m.id, 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.ResponseTime = time.Since(start) result.Status = StatusDown result.Error = fmt.Errorf("TLS connection failed: %w", err) return result } defer conn.Close() // Get SSL certificate info if len(conn.ConnectionState().PeerCertificates) > 0 { cert := conn.ConnectionState().PeerCertificates[0] result.SSLExpiry = &cert.NotAfter result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) } // Send Gemini request (just the URL followed by CRLF) // Format: gemini://host/path\r\n geminiURL := fmt.Sprintf("gemini://%s/\r\n", host) conn.SetDeadline(time.Now().Add(m.timeout)) if _, err := conn.Write([]byte(geminiURL)); err != nil { result.ResponseTime = time.Since(start) result.Status = StatusDown result.Error = fmt.Errorf("failed to send request: %w", err) return result } // Read response header (status code and meta) 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 status code (first two characters) if len(responseLine) < 2 { result.Status = StatusDown result.Error = fmt.Errorf("invalid Gemini response: too short") return result } statusCode := responseLine[0:2] // Gemini status codes: // 1x = INPUT // 2x = SUCCESS // 3x = REDIRECT // 4x = TEMPORARY FAILURE // 5x = PERMANENT FAILURE // 6x = CLIENT CERTIFICATE REQUIRED switch statusCode[0] { case '2': result.Status = StatusUp case '3': result.Status = StatusUp // Redirects are ok case '1', '6': result.Status = StatusDegraded // Input or cert required result.Error = fmt.Errorf("status %s: %s", statusCode, strings.TrimSpace(responseLine[3:])) default: result.Status = StatusDown result.Error = fmt.Errorf("status %s: %s", statusCode, strings.TrimSpace(responseLine[3:])) } return result }