package monitor import ( "context" "crypto/tls" "fmt" "io" "net" "net/http" "strings" "time" "github.com/Fuwn/kaze/internal/config" ) // HTTPMonitor monitors HTTP and HTTPS endpoints type HTTPMonitor struct { id string // unique identifier (group/name) name string group string monitorType string target string interval time.Duration timeout time.Duration retries int method string userAgent string headers map[string]string body string expectedStatus int expectedContent string verifySSL bool hideSSLDays bool roundResponseTime bool roundUptime bool client *http.Client } // NewHTTPMonitor creates a new HTTP/HTTPS monitor func NewHTTPMonitor(cfg config.MonitorConfig) (*HTTPMonitor, error) { // Validate target URL target := cfg.Target if cfg.Type == "https" && !strings.HasPrefix(target, "https://") { if strings.HasPrefix(target, "http://") { target = strings.Replace(target, "http://", "https://", 1) } else { target = "https://" + target } } else if cfg.Type == "http" && !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { target = "http://" + target } verifySSL := true if cfg.VerifySSL != nil { verifySSL = *cfg.VerifySSL } // Create HTTP client with custom transport transport := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: !verifySSL, }, DialContext: (&net.Dialer{ Timeout: cfg.Timeout.Duration, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: cfg.Timeout.Duration, ExpectContinueTimeout: 1 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } client := &http.Client{ Transport: transport, Timeout: cfg.Timeout.Duration, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } return &HTTPMonitor{ id: cfg.ID(), name: cfg.Name, group: cfg.Group, monitorType: cfg.Type, target: target, interval: cfg.Interval.Duration, timeout: cfg.Timeout.Duration, retries: cfg.Retries, method: cfg.Method, userAgent: cfg.UserAgent, headers: cfg.Headers, body: cfg.Body, expectedStatus: cfg.ExpectedStatus, expectedContent: cfg.ExpectedContent, verifySSL: verifySSL, hideSSLDays: cfg.HideSSLDays, roundResponseTime: cfg.RoundResponseTime, roundUptime: cfg.RoundUptime, client: client, }, nil } // ID returns the unique identifier for this monitor func (m *HTTPMonitor) ID() string { return m.id } // Name returns the monitor's name func (m *HTTPMonitor) Name() string { return m.name } // Group returns the group this monitor belongs to func (m *HTTPMonitor) Group() string { return m.group } // Type returns the monitor type func (m *HTTPMonitor) Type() string { return m.monitorType } // Target returns the monitor target func (m *HTTPMonitor) Target() string { return m.target } // Interval returns the check interval func (m *HTTPMonitor) Interval() time.Duration { return m.interval } // Retries returns the number of retry attempts func (m *HTTPMonitor) Retries() int { return m.retries } // HideSSLDays returns whether to hide SSL days from display func (m *HTTPMonitor) HideSSLDays() bool { return m.hideSSLDays } // RoundResponseTime returns whether to round response time func (m *HTTPMonitor) RoundResponseTime() bool { return m.roundResponseTime } // RoundUptime returns whether to round uptime percentage func (m *HTTPMonitor) RoundUptime() bool { return m.roundUptime } // Check performs the HTTP/HTTPS check func (m *HTTPMonitor) Check(ctx context.Context) *Result { result := &Result{ MonitorName: m.id, Timestamp: time.Now(), } // Create request var bodyReader io.Reader if m.body != "" { bodyReader = strings.NewReader(m.body) } req, err := http.NewRequestWithContext(ctx, m.method, m.target, bodyReader) if err != nil { result.Status = StatusDown result.Error = fmt.Errorf("failed to create request: %w", err) return result } // Set headers userAgent := m.userAgent if userAgent == "" { userAgent = "Kaze-Monitor/1.0" } req.Header.Set("User-Agent", userAgent) for key, value := range m.headers { req.Header.Set(key, value) } // Perform request and measure response time start := time.Now() resp, err := m.client.Do(req) result.ResponseTime = time.Since(start) if err != nil { result.Status = StatusDown result.Error = fmt.Errorf("request failed: %w", err) return result } defer resp.Body.Close() // Read body if we need to check content, otherwise discard var bodyContent string if m.expectedContent != "" { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { result.Status = StatusDown result.Error = fmt.Errorf("failed to read response body: %w", err) return result } bodyContent = string(bodyBytes) } else { // Discard body to allow connection reuse io.Copy(io.Discard, resp.Body) } result.StatusCode = resp.StatusCode // Check SSL certificate for HTTPS if m.monitorType == "https" && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { cert := resp.TLS.PeerCertificates[0] result.SSLExpiry = &cert.NotAfter result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) } // Determine status based on response code if resp.StatusCode == m.expectedStatus { result.Status = StatusUp } else if resp.StatusCode >= 200 && resp.StatusCode < 400 { // Got a success code but not the expected one result.Status = StatusDegraded result.Error = fmt.Errorf("unexpected status code: got %d, expected %d", resp.StatusCode, m.expectedStatus) } else { result.Status = StatusDown result.Error = fmt.Errorf("bad status code: %d", resp.StatusCode) } // Check expected content if specified if m.expectedContent != "" && result.Status == StatusUp { if !strings.Contains(bodyContent, m.expectedContent) { result.Status = StatusDown result.Error = fmt.Errorf("expected content not found in response") } } // 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 }