From 5ceaa097106325d8a82bab43ebe1054372686cd9 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 20 Jun 2025 19:42:24 +0700 Subject: [PATCH] Add SSL certificate checker Implement SSL certificate checking functionality --- server/service-operation/README.md | 2 +- server/service-operation/config/config.go | 2 +- .../handlers/operation_handler.go | 4 + server/service-operation/main.go | 18 +- .../monitoring/ssl_monitor.go | 314 ++++++++++++++++++ .../operations/ssl_extractor.go | 57 ++++ .../operations/ssl_operation.go | 159 +++++++++ .../service-operation/operations/ssl_utils.go | 88 +++++ .../operations/ssl_validator.go | 65 ++++ server/service-operation/pocketbase/ssl.go | 89 +++++ server/service-operation/types/operation.go | 13 +- server/service-operation/types/ssl.go | 108 ++++++ 12 files changed, 913 insertions(+), 6 deletions(-) create mode 100644 server/service-operation/monitoring/ssl_monitor.go create mode 100644 server/service-operation/operations/ssl_extractor.go create mode 100644 server/service-operation/operations/ssl_operation.go create mode 100644 server/service-operation/operations/ssl_utils.go create mode 100644 server/service-operation/operations/ssl_validator.go create mode 100644 server/service-operation/pocketbase/ssl.go create mode 100644 server/service-operation/types/ssl.go diff --git a/server/service-operation/README.md b/server/service-operation/README.md index 46084f2..00089b1 100644 --- a/server/service-operation/README.md +++ b/server/service-operation/README.md @@ -8,10 +8,10 @@ A Go-based microservice for service operations including ICMP ping, DNS resoluti - **ICMP Ping**: Full ping functionality with packet statistics - **DNS Resolution**: A, AAAA, MX, and TXT record lookups - **TCP Connectivity**: Port connectivity testing +- **SSL Certificate**: SSL Certificate Check - REST API endpoints - Health check endpoint - Configurable via environment variables -- Docker support - Comprehensive operation statistics ## API Endpoints diff --git a/server/service-operation/config/config.go b/server/service-operation/config/config.go index 0ff5cc9..8fce7c0 100644 --- a/server/service-operation/config/config.go +++ b/server/service-operation/config/config.go @@ -21,7 +21,7 @@ type Config struct { func Load() *Config { cfg := &Config{ - Port: getEnv("PORT", "8091"), + Port: getEnv("PORT", "8092"), DefaultCount: getEnvInt("DEFAULT_COUNT", 4), DefaultTimeout: getEnvDuration("DEFAULT_TIMEOUT", 3*time.Second), MaxCount: getEnvInt("MAX_COUNT", 20), diff --git a/server/service-operation/handlers/operation_handler.go b/server/service-operation/handlers/operation_handler.go index 5cc89b3..1666c85 100644 --- a/server/service-operation/handlers/operation_handler.go +++ b/server/service-operation/handlers/operation_handler.go @@ -79,6 +79,10 @@ func (h *OperationHandler) HandleOperation(w http.ResponseWriter, r *http.Reques } result, err = httpOp.Execute(url, method) + case types.OperationSSL: + sslOp := operations.NewSSLOperation(timeout) + result, err = sslOp.Execute(req.Host) + default: http.Error(w, "Invalid operation type", http.StatusBadRequest) return diff --git a/server/service-operation/main.go b/server/service-operation/main.go index 8ee0670..a78040e 100644 --- a/server/service-operation/main.go +++ b/server/service-operation/main.go @@ -21,6 +21,7 @@ func main() { // Initialize PocketBase client (no credentials required) var pbClient *pocketbase.PocketBaseClient var monitoringService *monitoring.MonitoringService + var sslMonitoringService *monitoring.SSLMonitoringService if cfg.PocketBaseEnabled { var err error @@ -35,6 +36,11 @@ func main() { monitoringService = monitoring.NewMonitoringService(pbClient) go monitoringService.Start() log.Println("Monitoring service started (public access mode)") + + // Initialize and start SSL monitoring service + sslMonitoringService = monitoring.NewSSLMonitoringService(pbClient) + go sslMonitoringService.Start() + log.Println("SSL monitoring service started") } } } @@ -63,13 +69,16 @@ func main() { if monitoringService != nil { log.Printf("Automatic service monitoring enabled") } + if sslMonitoringService != nil { + log.Printf("SSL certificate monitoring enabled") + } log.Printf("Endpoints:") - log.Printf(" POST /operation - Full operation test (ping, dns, tcp, http)") + log.Printf(" POST /operation - Full operation test (ping, dns, tcp, http, ssl)") log.Printf(" GET /operation/quick?type=&host= - Quick operation test") log.Printf(" POST /ping - Legacy ping endpoint") log.Printf(" GET /ping/quick?host= - Legacy quick ping test") log.Printf(" GET /health - Health check") - log.Printf("Supported operations: ping, dns, tcp, http") + log.Printf("Supported operations: ping, dns, tcp, http, ssl") // Setup graceful shutdown c := make(chan os.Signal, 1) @@ -77,10 +86,13 @@ func main() { go func() { <-c - log.Println("Shutting down monitoring service...") + log.Println("Shutting down monitoring services...") if monitoringService != nil { monitoringService.Stop() } + if sslMonitoringService != nil { + sslMonitoringService.Stop() + } log.Println("Service stopped") os.Exit(0) }() diff --git a/server/service-operation/monitoring/ssl_monitor.go b/server/service-operation/monitoring/ssl_monitor.go new file mode 100644 index 0000000..a5055b9 --- /dev/null +++ b/server/service-operation/monitoring/ssl_monitor.go @@ -0,0 +1,314 @@ +package monitoring + +import ( + "fmt" + "log" + "time" + + "service-operation/operations" + "service-operation/pocketbase" + "service-operation/types" +) + +type SSLMonitoringService struct { + pbClient *pocketbase.PocketBaseClient + stopChan chan bool + retryQueue map[string]int // Track retry count per certificate + maxRetries int +} + +func NewSSLMonitoringService(pbClient *pocketbase.PocketBaseClient) *SSLMonitoringService { + return &SSLMonitoringService{ + pbClient: pbClient, + stopChan: make(chan bool), + retryQueue: make(map[string]int), + maxRetries: 3, + } +} + +func (s *SSLMonitoringService) Start() { + ticker := time.NewTicker(1 * time.Minute) // Check every minute for scheduling + defer ticker.Stop() + + log.Println("SSL monitoring service started with interval and check_at scheduling") + + for { + select { + case <-ticker.C: + s.checkSSLCertificates() + case <-s.stopChan: + log.Println("SSL monitoring service stopped") + return + } + } +} + +func (s *SSLMonitoringService) Stop() { + s.stopChan <- true +} + +func (s *SSLMonitoringService) checkSSLCertificates() { + //log.Println("Fetching SSL certificates from PocketBase...") + + certificates, err := s.pbClient.GetSSLCertificates() + if err != nil { + log.Printf("Failed to fetch SSL certificates: %v", err) + return + } + + //log.Printf("Found %d SSL certificates to check", len(certificates)) + + for _, cert := range certificates { + if s.shouldCheckCertificate(cert) { + go s.checkSingleCertificateWithRetry(cert) + } + } +} + +func (s *SSLMonitoringService) shouldCheckCertificate(cert types.SSLCertificate) bool { + now := time.Now() + + // Priority 1: Check if check_at is set and is due + if cert.CheckAt != "" { + if checkAt, err := s.parseFlexibleTime(cert.CheckAt); err == nil { + if now.After(checkAt) || now.Equal(checkAt) { + log.Printf("Certificate %s is due for manual check (check_at: %s)", + cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + return true + } else { + //log.Printf("Certificate %s scheduled for later check (check_at: %s)", + //cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + //return false + } + } else { + log.Printf("Error parsing check_at for %s: %v", cert.Domain, err) + } + } + + // Priority 2: Check based on check_interval (in days) from last update + if cert.Updated == "" { + log.Printf("Certificate %s has never been checked, scheduling check", cert.Domain) + return true + } + + // Parse last check time from updated field + lastCheck, err := s.parseFlexibleTime(cert.Updated) + if err != nil { + log.Printf("Error parsing last check time for %s, scheduling check: %v", cert.Domain, err) + return true + } + + // Get check interval in days (default to 1 day if not set or invalid) + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + // Adjust check interval based on certificate status for critical certificates + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + + // Calculate next check time based on days + nextCheck := lastCheck.Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + shouldCheck := now.After(nextCheck) + + if shouldCheck { + log.Printf("Certificate %s is due for interval check (last: %s, interval: %d days)", + cert.Domain, lastCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + } else { + //log.Printf("Certificate %s not due yet (next check: %s, interval: %d days)", + //cert.Domain, nextCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + } + + return shouldCheck +} + +// parseFlexibleTime tries multiple time formats to parse timestamps +func (s *SSLMonitoringService) parseFlexibleTime(timeStr string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05.999Z", // ISO 8601 with milliseconds (PocketBase format) + "2006-01-02 15:04:05.999999Z", // ISO 8601 with microseconds + "2006-01-02 15:04:05Z", // ISO 8601 without milliseconds + "2006-01-02T15:04:05.999Z", // RFC3339 with milliseconds + "2006-01-02T15:04:05.999999Z", // RFC3339 with microseconds + "2006-01-02T15:04:05Z", // RFC3339 without milliseconds + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05.999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999999Z", + } + + for _, format := range formats { + if t, err := time.Parse(format, timeStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time string: %s", timeStr) +} + +// adjustCheckIntervalDays adjusts the check interval based on certificate status and days left +func (s *SSLMonitoringService) adjustCheckIntervalDays(cert types.SSLCertificate, defaultIntervalDays int) int { + // Check more frequently for certificates that are expiring soon or have errors + if cert.DaysLeft <= 7 { + return 1 // Check daily for certificates expiring within 7 days + } else if cert.DaysLeft <= 30 { + // Check every 2 days for certificates expiring within 30 days + if defaultIntervalDays > 2 { + return 2 + } + } else if cert.Status == "error" { + return 1 // Check daily for certificates with errors + } + + return defaultIntervalDays +} + +func (s *SSLMonitoringService) checkSingleCertificateWithRetry(cert types.SSLCertificate) { + retryCount := s.retryQueue[cert.ID] + + log.Printf("🔍 Checking SSL certificate for domain: %s (attempt %d/%d)", + cert.Domain, retryCount+1, s.maxRetries+1) + + result, err := s.performSSLCheck(cert.Domain) + + if err != nil && retryCount < s.maxRetries { + // Increment retry count and schedule retry + s.retryQueue[cert.ID] = retryCount + 1 + log.Printf("SSL check failed for %s, will retry (%d/%d): %v", + cert.Domain, retryCount+1, s.maxRetries, err) + + // Schedule retry with exponential backoff + go func() { + backoffDuration := time.Duration((retryCount + 1) * 30) * time.Second + time.Sleep(backoffDuration) + s.checkSingleCertificateWithRetry(cert) + }() + return + } + + // Reset retry count on success or max retries reached + delete(s.retryQueue, cert.ID) + + if err != nil { + log.Printf("❌ SSL check failed for domain %s after %d attempts: %v", + cert.Domain, s.maxRetries+1, err) + s.updateCertificateWithError(cert, err) + return + } + + // Update certificate with successful results + s.updateCertificateWithResults(cert, result) +} + +func (s *SSLMonitoringService) performSSLCheck(domain string) (*types.OperationResult, error) { + log.Printf("Performing SSL check for domain: %s", domain) + sslOp := operations.NewSSLOperation(30 * time.Second) + result, err := sslOp.Execute(domain) + + if err != nil { + log.Printf("SSL operation failed for %s: %v", domain, err) + return nil, err + } + + if result == nil { + log.Printf("SSL operation returned nil result for %s", domain) + return nil, fmt.Errorf("SSL check returned nil result") + } + + log.Printf("SSL check completed for %s: success=%v, days_left=%d", + domain, result.Success, result.SSLDaysLeft) + + return result, nil +} + +func (s *SSLMonitoringService) updateCertificateWithError(cert types.SSLCertificate, err error) { + log.Printf("Updating certificate %s with error status", cert.Domain) + + updateData := map[string]interface{}{ + "status": "error", + "updated": time.Now().Format(time.RFC3339), + "error_message": err.Error(), + } + + // Calculate next check time based on check_interval (in days) with shorter interval for errors + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 + } + // For errors, check again in half the normal interval (minimum 1 day) + errorIntervalDays := checkIntervalDays / 2 + if errorIntervalDays < 1 { + errorIntervalDays = 1 + } + + nextCheck := time.Now().Add(time.Duration(errorIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if updateErr := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); updateErr != nil { + log.Printf("Failed to update SSL certificate %s with error status: %v", cert.ID, updateErr) + } else { + log.Printf("📝 Updated certificate %s with error status (next check in %d days)", + cert.Domain, errorIntervalDays) + } +} + +func (s *SSLMonitoringService) updateCertificateWithResults(cert types.SSLCertificate, result *types.OperationResult) { + status := getSSLStatus(result) + + log.Printf("Updating certificate %s with results: status=%s, days_left=%d, issuer=%s", + cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer) + + updateData := map[string]interface{}{ + "status": status, + "valid_from": result.SSLValidFrom.Format(time.RFC3339), + "valid_till": result.SSLValidTill.Format(time.RFC3339), + "days_left": result.SSLDaysLeft, + "valid_days_to_expire": result.SSLDaysLeft, + "resolved_ip": result.SSLResolvedIP, + "issuer_cn": result.SSLIssuer, // Now contains organization name like "Google Trust Services" + "issued_to": result.SSLSubject, // Now contains organization name + "serial_number": result.SSLSerialNumber, + "cert_alg": result.SSLAlgorithm, + "cert_sans": result.SSLSANs, + "updated": time.Now().Format(time.RFC3339), + "error_message": "", // Clear any previous error + } + + // Calculate next check time based on check_interval (in days) and certificate status + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + nextCheck := time.Now().Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if err := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); err != nil { + log.Printf("Failed to update SSL certificate %s: %v", cert.ID, err) + } else { + log.Printf("✅ SSL certificate updated for %s: %s (%d days left, issuer: %s, next check in %d days)", + cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer, adjustedIntervalDays) + } +} + +func getSSLStatus(result *types.OperationResult) string { + if !result.Success { + return "error" + } + + if result.SSLDaysLeft <= 0 { + return "expired" + } else if result.SSLDaysLeft <= 7 { + return "critical" // Very urgent + } else if result.SSLDaysLeft <= 30 { + return "expiring_soon" + } + + return "valid" +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_extractor.go b/server/service-operation/operations/ssl_extractor.go new file mode 100644 index 0000000..3a1ad77 --- /dev/null +++ b/server/service-operation/operations/ssl_extractor.go @@ -0,0 +1,57 @@ + +package operations + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "fmt" + "net" +) + +// extractSANs extracts Subject Alternative Names from certificate +func (op *SSLOperation) extractSANs(cert *x509.Certificate) []string { + sans := make([]string, 0) + + // Add DNS names + sans = append(sans, cert.DNSNames...) + + // Add IP addresses + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + + // Add email addresses + sans = append(sans, cert.EmailAddresses...) + + // Add URIs + for _, uri := range cert.URIs { + sans = append(sans, uri.String()) + } + + return sans +} + +// getResolvedIP resolves the domain to its IP address +func (op *SSLOperation) getResolvedIP(hostname string) string { + ips, err := net.LookupIP(hostname) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} + +// getCertificateAlgorithm returns detailed algorithm information +func (op *SSLOperation) getCertificateAlgorithm(cert *x509.Certificate) string { + algorithm := cert.SignatureAlgorithm.String() + + // Add key size information if available + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + algorithm += fmt.Sprintf(" (RSA %d-bit)", pub.N.BitLen()) + case *ecdsa.PublicKey: + algorithm += fmt.Sprintf(" (ECDSA %d-bit)", pub.Curve.Params().BitSize) + } + + return algorithm +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_operation.go b/server/service-operation/operations/ssl_operation.go new file mode 100644 index 0000000..398adbe --- /dev/null +++ b/server/service-operation/operations/ssl_operation.go @@ -0,0 +1,159 @@ + +package operations + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "time" + + "service-operation/types" +) + +type SSLOperation struct { + timeout time.Duration +} + +func NewSSLOperation(timeout time.Duration) *SSLOperation { + return &SSLOperation{ + timeout: timeout, + } +} + +func (op *SSLOperation) Execute(domain string) (*types.OperationResult, error) { + startTime := time.Now() + + // Clean and normalize domain + domain = op.normalizeDomain(domain) + + // Validate domain format + if domain == "" { + return op.createErrorResult(domain, startTime, "domain cannot be empty") + } + + // Add port if not present + host := domain + if !strings.Contains(host, ":") { + host = host + ":443" + } + + // Set up TLS connection with timeout + dialer := &net.Dialer{ + Timeout: op.timeout, + } + + // Create TLS config with proper verification + tlsConfig := &tls.Config{ + ServerName: strings.Split(host, ":")[0], + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + // Attempt TLS connection + conn, err := tls.DialWithDialer(dialer, "tcp", host, tlsConfig) + + endTime := time.Now() + responseTime := endTime.Sub(startTime) + + if err != nil { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: fmt.Sprintf("TLS connection failed: %v", err), + StartTime: startTime, + EndTime: endTime, + }, nil + } + defer conn.Close() + + // Get certificate chain information + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: "No certificates found in chain", + StartTime: startTime, + EndTime: endTime, + }, nil + } + + cert := state.PeerCertificates[0] + hostname := strings.Split(host, ":")[0] + + // Perform comprehensive certificate validation + validationError := op.validateCertificate(cert, hostname) + + // Calculate days left until expiration + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + + // Extract Subject Alternative Names + sans := op.extractSANs(cert) + + // Get resolved IP address + resolvedIP := op.getResolvedIP(hostname) + + // Extract certificate algorithm information + algorithm := op.getCertificateAlgorithm(cert) + + // Extract issuer organization (O=) instead of full distinguished name + issuerOrganization := op.extractIssuerOrganization(cert.Issuer) + + // Extract subject organization (O=) instead of full distinguished name + subjectOrganization := op.extractSubjectOrganization(cert.Subject) + + // Check if certificate is valid (not expired and passes validation) + isValid := validationError == nil && time.Now().Before(cert.NotAfter) && time.Now().After(cert.NotBefore) + + // Build detailed error message if validation failed + errorMsg := "" + if validationError != nil { + errorMsg = validationError.Error() + } else if time.Now().After(cert.NotAfter) { + errorMsg = "Certificate has expired" + } else if time.Now().Before(cert.NotBefore) { + errorMsg = "Certificate is not yet valid" + } + + // Create comprehensive result + result := &types.OperationResult{ + Type: types.OperationSSL, + Host: hostname, + Success: isValid, + ResponseTime: responseTime, + Error: errorMsg, + StartTime: startTime, + EndTime: endTime, + + // SSL specific fields - using organization instead of full DN + SSLValidFrom: cert.NotBefore, + SSLValidTill: cert.NotAfter, + SSLDaysLeft: daysLeft, + SSLIssuer: issuerOrganization, // Now shows "Google Trust Services" instead of full DN + SSLSubject: subjectOrganization, // Now shows organization instead of full DN + SSLSerialNumber: cert.SerialNumber.String(), + SSLAlgorithm: algorithm, + SSLSANs: strings.Join(sans, ","), + SSLResolvedIP: resolvedIP, + } + + return result, nil +} + +// createErrorResult creates a standardized error result +func (op *SSLOperation) createErrorResult(domain string, startTime time.Time, errorMsg string) (*types.OperationResult, error) { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: domain, + Success: false, + ResponseTime: time.Since(startTime), + Error: errorMsg, + StartTime: startTime, + EndTime: time.Now(), + }, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_utils.go b/server/service-operation/operations/ssl_utils.go new file mode 100644 index 0000000..555b4b2 --- /dev/null +++ b/server/service-operation/operations/ssl_utils.go @@ -0,0 +1,88 @@ + +package operations + +import ( + "crypto/x509/pkix" + "fmt" + "strings" +) + +// normalizeDomain cleans and normalizes the domain input +func (op *SSLOperation) normalizeDomain(domain string) string { + // Remove protocol prefixes + domain = strings.Replace(domain, "https://", "", 1) + domain = strings.Replace(domain, "http://", "", 1) + + // Remove trailing slash and path + if idx := strings.Index(domain, "/"); idx != -1 { + domain = domain[:idx] + } + + // Trim whitespace + domain = strings.TrimSpace(domain) + + return domain +} + +// formatDistinguishedName formats the certificate distinguished name +func (op *SSLOperation) formatDistinguishedName(name pkix.Name) string { + var parts []string + + if name.CommonName != "" { + parts = append(parts, fmt.Sprintf("CN=%s", name.CommonName)) + } + + for _, org := range name.Organization { + parts = append(parts, fmt.Sprintf("O=%s", org)) + } + + for _, orgUnit := range name.OrganizationalUnit { + parts = append(parts, fmt.Sprintf("OU=%s", orgUnit)) + } + + for _, country := range name.Country { + parts = append(parts, fmt.Sprintf("C=%s", country)) + } + + for _, locality := range name.Locality { + parts = append(parts, fmt.Sprintf("L=%s", locality)) + } + + for _, province := range name.Province { + parts = append(parts, fmt.Sprintf("ST=%s", province)) + } + + return strings.Join(parts, ", ") +} + +// extractIssuerOrganization extracts only the organization (O=) from the issuer distinguished name +func (op *SSLOperation) extractIssuerOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} + +// extractSubjectOrganization extracts only the organization (O=) from the subject distinguished name +func (op *SSLOperation) extractSubjectOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_validator.go b/server/service-operation/operations/ssl_validator.go new file mode 100644 index 0000000..602ea7f --- /dev/null +++ b/server/service-operation/operations/ssl_validator.go @@ -0,0 +1,65 @@ +package operations + +import ( + "crypto/x509" + "fmt" + "time" +) + +// validateCertificate performs comprehensive certificate validation +func (op *SSLOperation) validateCertificate(cert *x509.Certificate, hostname string) error { + now := time.Now() + + // Check if certificate is expired or not yet valid + if now.Before(cert.NotBefore) { + return fmt.Errorf("certificate is not yet valid (valid from: %v)", cert.NotBefore.Format("2006-01-02 15:04:05")) + } + if now.After(cert.NotAfter) { + return fmt.Errorf("certificate has expired (expired on: %v)", cert.NotAfter.Format("2006-01-02 15:04:05")) + } + + // Verify hostname matches certificate + if err := cert.VerifyHostname(hostname); err != nil { + return fmt.Errorf("hostname verification failed: %v", err) + } + + // Check key usage - certificates should have digital signature capability + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("certificate missing required digital signature key usage") + } + + // Check if certificate is self-signed (basic check) + if cert.Issuer.CommonName == cert.Subject.CommonName && len(cert.Subject.Organization) == 0 { + return fmt.Errorf("certificate appears to be self-signed") + } + + // Validate certificate chain if intermediate certificates are present + if err := op.validateCertificateChain(cert); err != nil { + return fmt.Errorf("certificate chain validation failed: %v", err) + } + + return nil +} + +// validateCertificateChain performs basic certificate chain validation +func (op *SSLOperation) validateCertificateChain(cert *x509.Certificate) error { + // Check if the certificate has proper extensions for SSL/TLS + hasServerAuth := false + for _, usage := range cert.ExtKeyUsage { + if usage == x509.ExtKeyUsageServerAuth { + hasServerAuth = true + break + } + } + + if !hasServerAuth { + return fmt.Errorf("certificate does not have server authentication extension") + } + + // Check certificate version (should be v3 for modern certificates) + if cert.Version < 3 { + return fmt.Errorf("certificate version %d is outdated (should be v3)", cert.Version) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/ssl.go b/server/service-operation/pocketbase/ssl.go new file mode 100644 index 0000000..79a4008 --- /dev/null +++ b/server/service-operation/pocketbase/ssl.go @@ -0,0 +1,89 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "service-operation/types" +) + +type SSLCertificatesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []types.SSLCertificate `json:"items"` +} + +func (c *PocketBaseClient) GetSSLCertificates() ([]types.SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records", c.baseURL) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificates: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var response SSLCertificatesResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificates response: %v", err) + } + + return response.Items, nil +} + +func (c *PocketBaseClient) UpdateSSLCertificate(id string, data map[string]interface{}) error { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal SSL certificate data: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create SSL certificate update request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update SSL certificate, status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) GetSSLCertificateByID(id string) (*types.SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var cert types.SSLCertificate + if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificate response: %v", err) + } + + return &cert, nil +} \ No newline at end of file diff --git a/server/service-operation/types/operation.go b/server/service-operation/types/operation.go index a6f81ff..f36cb0a 100644 --- a/server/service-operation/types/operation.go +++ b/server/service-operation/types/operation.go @@ -1,4 +1,3 @@ - package types import "time" @@ -10,6 +9,7 @@ const ( OperationDNS OperationType = "dns" OperationTCP OperationType = "tcp" OperationHTTP OperationType = "http" + OperationSSL OperationType = "ssl" ) type OperationRequest struct { @@ -56,6 +56,17 @@ type OperationResult struct { ContentLength int64 `json:"content_length,omitempty"` ResponseBody string `json:"response_body,omitempty"` + // SSL specific fields + SSLValidFrom time.Time `json:"ssl_valid_from,omitempty"` + SSLValidTill time.Time `json:"ssl_valid_till,omitempty"` + SSLDaysLeft int `json:"ssl_days_left,omitempty"` + SSLIssuer string `json:"ssl_issuer,omitempty"` + SSLSubject string `json:"ssl_subject,omitempty"` + SSLSerialNumber string `json:"ssl_serial_number,omitempty"` + SSLAlgorithm string `json:"ssl_algorithm,omitempty"` + SSLSANs string `json:"ssl_sans,omitempty"` + SSLResolvedIP string `json:"ssl_resolved_ip,omitempty"` + StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` } \ No newline at end of file diff --git a/server/service-operation/types/ssl.go b/server/service-operation/types/ssl.go new file mode 100644 index 0000000..02e53dd --- /dev/null +++ b/server/service-operation/types/ssl.go @@ -0,0 +1,108 @@ + +package types + +import ( + "encoding/json" + "strconv" + "time" +) + +type SSLCertificate struct { + ID string `json:"id"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + Domain string `json:"domain"` + IssuerO string `json:"issuer_o"` + Status string `json:"status"` + LastNotified string `json:"last_notified"` + WarningThreshold int `json:"warning_threshold"` + ExpiryThreshold int `json:"expiry_threshold"` + NotificationChannel string `json:"notification_channel"` + ValidFrom string `json:"valid_from"` + SerialNumber string `json:"serial_number"` // Changed to string to handle large numbers + IssuedTo string `json:"issued_to"` + ValidTill string `json:"valid_till"` + ValidityDays int `json:"validity_days"` + DaysLeft int `json:"days_left"` + ValidDaysToExpire int `json:"valid_days_to_expire"` + ResolvedIP string `json:"resolved_ip"` + IssuerCN string `json:"issuer_cn"` + CertAlg string `json:"cert_alg"` + CertSans string `json:"cert_sans"` + CheckInterval int `json:"check_interval"` + CheckAt string `json:"check_at"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// Custom unmarshaler to handle check_interval as both string and int, and serial_number as string +func (s *SSLCertificate) UnmarshalJSON(data []byte) error { + // Create a temporary struct with flexible types + type Alias SSLCertificate + aux := &struct { + CheckInterval interface{} `json:"check_interval"` + SerialNumber interface{} `json:"serial_number"` + *Alias + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle check_interval conversion + switch v := aux.CheckInterval.(type) { + case string: + if v == "" { + s.CheckInterval = 1440 // Default 24 hours in minutes + } else { + interval, err := strconv.Atoi(v) + if err != nil { + s.CheckInterval = 1440 // Default on error + } else { + s.CheckInterval = interval + } + } + case float64: + s.CheckInterval = int(v) + case int: + s.CheckInterval = v + default: + s.CheckInterval = 1440 // Default 24 hours in minutes + } + + // Handle serial_number conversion to string + switch v := aux.SerialNumber.(type) { + case string: + s.SerialNumber = v + case float64: + // Handle scientific notation by converting to string + s.SerialNumber = strconv.FormatFloat(v, 'f', 0, 64) + case int64: + s.SerialNumber = strconv.FormatInt(v, 10) + case int: + s.SerialNumber = strconv.Itoa(v) + default: + s.SerialNumber = "" + } + + return nil +} + +type SSLCheckResult struct { + Domain string `json:"domain"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ValidFrom time.Time `json:"valid_from"` + ValidTill time.Time `json:"valid_till"` + DaysLeft int `json:"days_left"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serial_number"` + Algorithm string `json:"algorithm"` + SANs []string `json:"sans"` + ResponseTime time.Duration `json:"response_time"` + ResolvedIP string `json:"resolved_ip"` + CheckedAt time.Time `json:"checked_at"` +} \ No newline at end of file