From e9153328fc229f66f10567e057009d20420e86fb Mon Sep 17 00:00:00 2001 From: WCY-dt <834421194@qq.com> Date: Fri, 26 Sep 2025 18:24:48 +0800 Subject: [PATCH 1/3] feature: refactor notification handling to improve endpoint reporting and streamline file operations --- internal/notifier/notify.go | 222 ++++++++++++--- internal/notifier/notify_test.go | 468 +++++++++++++++++++++++++++++++ 2 files changed, 649 insertions(+), 41 deletions(-) create mode 100644 internal/notifier/notify_test.go diff --git a/internal/notifier/notify.go b/internal/notifier/notify.go index 073554ffdf..95735087cc 100644 --- a/internal/notifier/notify.go +++ b/internal/notifier/notify.go @@ -1,8 +1,11 @@ package notifier import ( + "fmt" "log" "os" + "strings" + "time" "github.com/wcy-dt/ponghub/internal/types/structures/checker" "github.com/wcy-dt/ponghub/internal/types/types/chk_result" @@ -11,74 +14,211 @@ import ( // WriteNotifications sends notifications based on the service check results func WriteNotifications(checkResult []checker.Service, certNotifyDays int) { - // find all endpoints with status NONE - statusNoneEndpoints := make(map[string][]string) + statusNoneEndpoints := collectUnavailableEndpoints(checkResult) + certProblemEndpoints := collectCertProblemEndpoints(checkResult, certNotifyDays) + + if len(statusNoneEndpoints) == 0 && len(certProblemEndpoints) == 0 { + // if no endpoints have issues, do nothing + return + } + + notifyPath := default_config.GetNotifyPath() + if err := removeExistingNotifyFile(notifyPath); err != nil { + return + } + + f, err := os.Create(notifyPath) + if err != nil { + log.Println("Error creating notify file:", err) + return + } + defer func() { + if err := f.Close(); err != nil { + log.Println("Error closing notify file:", err) + } + }() + + writeNotificationReport(f, statusNoneEndpoints, certProblemEndpoints) +} + +// collectUnavailableEndpoints finds all endpoints with status NONE +func collectUnavailableEndpoints(checkResult []checker.Service) map[string][]checker.Endpoint { + statusNoneEndpoints := make(map[string][]checker.Endpoint) for _, serviceResult := range checkResult { for _, endpointResult := range serviceResult.Endpoints { if endpointResult.Status == chk_result.NONE { - statusNoneEndpoints[serviceResult.Name] = append(statusNoneEndpoints[serviceResult.Name], endpointResult.URL) + statusNoneEndpoints[serviceResult.Name] = append(statusNoneEndpoints[serviceResult.Name], endpointResult) } } } + return statusNoneEndpoints +} - // find all endpoints whose certificates are expired or has less than 7 days remaining - certProblemEndpoints := make(map[string][]string) +// collectCertProblemEndpoints finds all endpoints whose certificates are expired or expiring soon +func collectCertProblemEndpoints(checkResult []checker.Service, certNotifyDays int) map[string][]checker.Endpoint { + certProblemEndpoints := make(map[string][]checker.Endpoint) for _, serviceResult := range checkResult { for _, endpointResult := range serviceResult.Endpoints { if endpointResult.IsHTTPS && (endpointResult.IsCertExpired || endpointResult.CertRemainingDays <= certNotifyDays) { - certProblemEndpoints[serviceResult.Name] = append(certProblemEndpoints[serviceResult.Name], endpointResult.URL) + certProblemEndpoints[serviceResult.Name] = append(certProblemEndpoints[serviceResult.Name], endpointResult) } } } + return certProblemEndpoints +} - // output to file default_config.GetNotifyPath() - notifyPath := default_config.GetNotifyPath() +// removeExistingNotifyFile removes the existing notify file if it exists +func removeExistingNotifyFile(notifyPath string) error { if err := os.Remove(notifyPath); err != nil && !os.IsNotExist(err) { log.Println("Error removing notify file:", err) - return + return err } + return nil +} + +// writeNotificationReport writes the complete notification report to the file +func writeNotificationReport(f *os.File, statusNoneEndpoints, certProblemEndpoints map[string][]checker.Endpoint) { + writeHeader(f) + writeUnavailableServices(f, statusNoneEndpoints) + writeCertificateIssues(f, certProblemEndpoints) + writeSummary(f, statusNoneEndpoints, certProblemEndpoints) +} + +// writeHeader writes the report header with timestamp +func writeHeader(f *os.File) { + currentTime := time.Now().Format("2006-01-02 15:04:05") + writeToFile(f, fmt.Sprintf("=== PongHub Service Status Report ===\n")) + writeToFile(f, fmt.Sprintf("Generated at: %s\n\n", currentTime)) +} + +// writeUnavailableServices writes information about unavailable services +func writeUnavailableServices(f *os.File, statusNoneEndpoints map[string][]checker.Endpoint) { if len(statusNoneEndpoints) == 0 { - // if no endpointURLs are down, do nothing return } - // new notify file - f, err := os.Create(notifyPath) - if err != nil { - log.Println("Error creating notify file:", err) + writeToFile(f, "🔴 UNAVAILABLE SERVICES:\n") + writeToFile(f, strings.Repeat("=", 50)+"\n") + + for serviceName, endpoints := range statusNoneEndpoints { + writeToFile(f, fmt.Sprintf("\n📋 Service: %s\n", serviceName)) + for _, endpoint := range endpoints { + writeUnavailableEndpointDetails(f, endpoint) + } + } +} + +// writeUnavailableEndpointDetails writes detailed information about an unavailable endpoint +func writeUnavailableEndpointDetails(f *os.File, endpoint checker.Endpoint) { + writeToFile(f, fmt.Sprintf(" • URL: %s\n", endpoint.URL)) + writeToFile(f, fmt.Sprintf(" Method: %s\n", endpoint.Method)) + + if endpoint.StatusCode > 0 { + writeToFile(f, fmt.Sprintf(" Status Code: %d\n", endpoint.StatusCode)) + } + if endpoint.ResponseTime > 0 { + writeToFile(f, fmt.Sprintf(" Response Time: %v\n", endpoint.ResponseTime)) + } + + writeToFile(f, fmt.Sprintf(" Attempts: %d/%d successful\n", endpoint.SuccessNum, endpoint.AttemptNum)) + writeToFile(f, fmt.Sprintf(" Check Time: %s - %s\n", endpoint.StartTime, endpoint.EndTime)) + + writeFailureDetails(f, endpoint.FailureDetails) + writeResponseBody(f, endpoint.ResponseBody) + writeToFile(f, "\n") +} + +// writeFailureDetails writes failure details if available +func writeFailureDetails(f *os.File, failureDetails []string) { + if len(failureDetails) == 0 { return } - defer func() { - if err := f.Close(); err != nil { - log.Println("Error closing notify file:", err) - } - }() - // write none status endpoints to file - for serviceName, endpointURLs := range statusNoneEndpoints { - if _, err := f.WriteString(serviceName + "\n"); err != nil { - log.Println("Error writing to notify file:", err) - return - } - for _, endpointURL := range endpointURLs { - if _, err := f.WriteString("\t" + endpointURL + " is unavailable.\n"); err != nil { - log.Println("Error writing to notify file:", err) - return - } - } + writeToFile(f, " Failure Details:\n") + for _, detail := range failureDetails { + writeToFile(f, fmt.Sprintf(" - %s\n", detail)) + } +} + +// writeResponseBody writes response body if available and not too long +func writeResponseBody(f *os.File, responseBody string) { + if len(responseBody) > 0 && len(responseBody) < 500 { + writeToFile(f, fmt.Sprintf(" Response Body: %s\n", strings.TrimSpace(responseBody))) } +} + +// writeCertificateIssues writes information about certificate issues +func writeCertificateIssues(f *os.File, certProblemEndpoints map[string][]checker.Endpoint) { + if len(certProblemEndpoints) == 0 { + return + } + + writeToFile(f, "\n🔐 CERTIFICATE ISSUES:\n") + writeToFile(f, strings.Repeat("=", 50)+"\n") - // write cert problem endpoints to file - for serviceName, endpointURLs := range certProblemEndpoints { - if _, err := f.WriteString(serviceName + "\n"); err != nil { - log.Println("Error writing to notify file:", err) - return + for serviceName, endpoints := range certProblemEndpoints { + writeToFile(f, fmt.Sprintf("\n📋 Service: %s\n", serviceName)) + for _, endpoint := range endpoints { + writeCertEndpointDetails(f, endpoint) } - for _, endpointURL := range endpointURLs { - if _, err := f.WriteString("\t" + endpointURL + " has certificate issues.\n"); err != nil { - log.Println("Error writing to notify file:", err) - return - } + } +} + +// writeCertEndpointDetails writes detailed information about certificate issues +func writeCertEndpointDetails(f *os.File, endpoint checker.Endpoint) { + writeToFile(f, fmt.Sprintf(" • URL: %s\n", endpoint.URL)) + + writeCertificateStatus(f, endpoint) + + writeToFile(f, fmt.Sprintf(" Days Remaining: %d\n", endpoint.CertRemainingDays)) + if endpoint.StatusCode > 0 { + writeToFile(f, fmt.Sprintf(" Status Code: %d\n", endpoint.StatusCode)) + } + if endpoint.ResponseTime > 0 { + writeToFile(f, fmt.Sprintf(" Response Time: %v\n", endpoint.ResponseTime)) + } + writeToFile(f, fmt.Sprintf(" Check Time: %s - %s\n", endpoint.StartTime, endpoint.EndTime)) + writeToFile(f, "\n") +} + +// writeCertificateStatus writes the certificate status with appropriate emoji and message +func writeCertificateStatus(f *os.File, endpoint checker.Endpoint) { + if endpoint.IsCertExpired { + writeToFile(f, " ❌ Certificate Status: EXPIRED\n") + } else { + certStatus := "⚠️ Certificate Status: EXPIRES SOON" + if endpoint.CertRemainingDays <= 1 { + certStatus = "🚨 Certificate Status: EXPIRES IN 1 DAY OR LESS" } + writeToFile(f, fmt.Sprintf(" %s\n", certStatus)) + } +} + +// writeSummary writes the summary statistics +func writeSummary(f *os.File, statusNoneEndpoints, certProblemEndpoints map[string][]checker.Endpoint) { + writeToFile(f, "\n📊 SUMMARY:\n") + writeToFile(f, strings.Repeat("=", 50)+"\n") + + unavailableCount := countEndpoints(statusNoneEndpoints) + certIssueCount := countEndpoints(certProblemEndpoints) + + writeToFile(f, fmt.Sprintf("Unavailable Endpoints: %d\n", unavailableCount)) + writeToFile(f, fmt.Sprintf("Certificate Issues: %d\n", certIssueCount)) + writeToFile(f, fmt.Sprintf("Total Issues: %d\n", unavailableCount+certIssueCount)) +} + +// countEndpoints counts the total number of endpoints in the map +func countEndpoints(endpointsMap map[string][]checker.Endpoint) int { + count := 0 + for _, endpoints := range endpointsMap { + count += len(endpoints) + } + return count +} + +// writeToFile is a helper function that writes to file and handles errors +func writeToFile(f *os.File, content string) { + if _, err := f.WriteString(content); err != nil { + log.Println("Error writing to notify file:", err) } } diff --git a/internal/notifier/notify_test.go b/internal/notifier/notify_test.go new file mode 100644 index 0000000000..b758d9aab3 --- /dev/null +++ b/internal/notifier/notify_test.go @@ -0,0 +1,468 @@ +package notifier + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/wcy-dt/ponghub/internal/types/structures/checker" + "github.com/wcy-dt/ponghub/internal/types/types/chk_result" +) + +func TestCollectUnavailableEndpoints(t *testing.T) { + checkResult := []checker.Service{ + { + Name: "Service1", + Endpoints: []checker.Endpoint{ + {URL: "http://example.com", Status: chk_result.NONE}, + {URL: "http://good.com", Status: chk_result.ALL}, + }, + }, + { + Name: "Service2", + Endpoints: []checker.Endpoint{ + {URL: "http://bad.com", Status: chk_result.NONE}, + }, + }, + } + + result := collectUnavailableEndpoints(checkResult) + + if len(result) != 2 { + t.Errorf("Expected 2 services with unavailable endpoints, got %d", len(result)) + } + + if len(result["Service1"]) != 1 { + t.Errorf("Expected 1 unavailable endpoint for Service1, got %d", len(result["Service1"])) + } + + if len(result["Service2"]) != 1 { + t.Errorf("Expected 1 unavailable endpoint for Service2, got %d", len(result["Service2"])) + } + + if result["Service1"][0].URL != "http://example.com" { + t.Errorf("Expected URL http://example.com, got %s", result["Service1"][0].URL) + } +} + +func TestCollectCertProblemEndpoints(t *testing.T) { + checkResult := []checker.Service{ + { + Name: "Service1", + Endpoints: []checker.Endpoint{ + { + URL: "https://expired.com", + IsHTTPS: true, + IsCertExpired: true, + CertRemainingDays: -1, + }, + { + URL: "https://expiring.com", + IsHTTPS: true, + IsCertExpired: false, + CertRemainingDays: 5, + }, + { + URL: "https://good.com", + IsHTTPS: true, + IsCertExpired: false, + CertRemainingDays: 30, + }, + { + URL: "http://notssl.com", + IsHTTPS: false, + }, + }, + }, + } + + certNotifyDays := 7 + result := collectCertProblemEndpoints(checkResult, certNotifyDays) + + if len(result) != 1 { + t.Errorf("Expected 1 service with cert problems, got %d", len(result)) + } + + if len(result["Service1"]) != 2 { + t.Errorf("Expected 2 endpoints with cert problems, got %d", len(result["Service1"])) + } + + // Check if both expired and soon-to-expire endpoints are collected + urls := []string{result["Service1"][0].URL, result["Service1"][1].URL} + expectedURLs := []string{"https://expired.com", "https://expiring.com"} + + for _, expectedURL := range expectedURLs { + found := false + for _, url := range urls { + if url == expectedURL { + found = true + break + } + } + if !found { + t.Errorf("Expected to find URL %s in cert problem endpoints", expectedURL) + } + } +} + +func TestCountEndpoints(t *testing.T) { + endpointsMap := map[string][]checker.Endpoint{ + "Service1": { + {URL: "http://example1.com"}, + {URL: "http://example2.com"}, + }, + "Service2": { + {URL: "http://example3.com"}, + }, + } + + count := countEndpoints(endpointsMap) + expectedCount := 3 + + if count != expectedCount { + t.Errorf("Expected count %d, got %d", expectedCount, count) + } +} + +func TestRemoveExistingNotifyFile(t *testing.T) { + // Create a temporary file + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_notify.txt") + + // Create the file + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("Failed to close test file: %v", err) + } + + // Verify file exists + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Fatal("Test file should exist") + } + + // Remove the file + err = removeExistingNotifyFile(testFile) + if err != nil { + t.Errorf("removeExistingNotifyFile failed: %v", err) + } + + // Verify file is removed + if _, err := os.Stat(testFile); !os.IsNotExist(err) { + t.Error("Test file should be removed") + } + + // Test removing non-existent file (should not error) + err = removeExistingNotifyFile(testFile) + if err != nil { + t.Errorf("removeExistingNotifyFile should not error on non-existent file: %v", err) + } +} + +func TestWriteToFile(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_write.txt") + + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer func() { + if err := f.Close(); err != nil { + t.Errorf("Failed to close test file: %v", err) + } + }() + + testContent := "Hello, World!" + writeToFile(f, testContent) + + // Read back the content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if string(content) != testContent { + t.Errorf("Expected content %q, got %q", testContent, string(content)) + } +} + +func TestWriteNotificationReport(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_report.txt") + + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer func() { + if err := f.Close(); err != nil { + t.Errorf("Failed to close test file: %v", err) + } + }() + + // Create test data + statusNoneEndpoints := map[string][]checker.Endpoint{ + "TestService": { + { + URL: "http://test.com", + Method: "GET", + StatusCode: 500, + ResponseTime: 100 * time.Millisecond, + AttemptNum: 3, + SuccessNum: 0, + StartTime: "2025-01-01 10:00:00", + EndTime: "2025-01-01 10:00:01", + FailureDetails: []string{"Connection timeout", "Server error"}, + ResponseBody: "Internal Server Error", + }, + }, + } + + certProblemEndpoints := map[string][]checker.Endpoint{ + "SSLService": { + { + URL: "https://ssl.com", + IsHTTPS: true, + IsCertExpired: true, + CertRemainingDays: -5, + StatusCode: 200, + ResponseTime: 50 * time.Millisecond, + StartTime: "2025-01-01 10:00:00", + EndTime: "2025-01-01 10:00:01", + }, + }, + } + + writeNotificationReport(f, statusNoneEndpoints, certProblemEndpoints) + + // Read back the content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + contentStr := string(content) + + // Check for expected sections + expectedSections := []string{ + "=== PongHub Service Status Report ===", + "🔴 UNAVAILABLE SERVICES:", + "📋 Service: TestService", + "• URL: http://test.com", + "Method: GET", + "Status Code: 500", + "Response Time: 100ms", + "Attempts: 0/3 successful", + "Failure Details:", + "Connection timeout", + "Server error", + "Response Body: Internal Server Error", + "🔐 CERTIFICATE ISSUES:", + "📋 Service: SSLService", + "• URL: https://ssl.com", + "❌ Certificate Status: EXPIRED", + "Days Remaining: -5", + "📊 SUMMARY:", + "Unavailable Endpoints: 1", + "Certificate Issues: 1", + "Total Issues: 2", + } + + for _, section := range expectedSections { + if !strings.Contains(contentStr, section) { + t.Errorf("Expected to find section %q in report", section) + } + } +} + +func TestWriteCertificateStatus(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_cert_status.txt") + + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer func() { + if err := f.Close(); err != nil { + t.Errorf("Failed to close test file: %v", err) + } + }() + + tests := []struct { + name string + endpoint checker.Endpoint + expected string + }{ + { + name: "Expired certificate", + endpoint: checker.Endpoint{ + IsCertExpired: true, + CertRemainingDays: -1, + }, + expected: "❌ Certificate Status: EXPIRED", + }, + { + name: "Certificate expires in 1 day", + endpoint: checker.Endpoint{ + IsCertExpired: false, + CertRemainingDays: 1, + }, + expected: "🚨 Certificate Status: EXPIRES IN 1 DAY OR LESS", + }, + { + name: "Certificate expires soon", + endpoint: checker.Endpoint{ + IsCertExpired: false, + CertRemainingDays: 5, + }, + expected: "⚠️ Certificate Status: EXPIRES SOON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear file content + if err := f.Truncate(0); err != nil { + t.Fatalf("Failed to truncate file: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("Failed to seek file: %v", err) + } + + writeCertificateStatus(f, tt.endpoint) + + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if !strings.Contains(string(content), tt.expected) { + t.Errorf("Expected content to contain %q, got %q", tt.expected, string(content)) + } + }) + } +} + +func TestWriteFailureDetails(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_failure.txt") + + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer func() { + if err := f.Close(); err != nil { + t.Errorf("Failed to close test file: %v", err) + } + }() + + // Test with failure details + failureDetails := []string{"Error 1", "Error 2"} + writeFailureDetails(f, failureDetails) + + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "Failure Details:") { + t.Error("Expected to find 'Failure Details:' header") + } + if !strings.Contains(contentStr, "- Error 1") { + t.Error("Expected to find '- Error 1'") + } + if !strings.Contains(contentStr, "- Error 2") { + t.Error("Expected to find '- Error 2'") + } + + // Test with empty failure details + if err := f.Truncate(0); err != nil { + t.Fatalf("Failed to truncate file: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("Failed to seek file: %v", err) + } + writeFailureDetails(f, []string{}) + + content, err = os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if len(content) != 0 { + t.Error("Expected no content for empty failure details") + } +} + +func TestWriteResponseBody(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test_response.txt") + + f, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer func() { + if err := f.Close(); err != nil { + t.Errorf("Failed to close test file: %v", err) + } + }() + + // Test with short response body + shortBody := "Short response" + writeResponseBody(f, shortBody) + + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if !strings.Contains(string(content), shortBody) { + t.Errorf("Expected to find response body %q", shortBody) + } + + // Test with long response body (should be skipped) + if err := f.Truncate(0); err != nil { + t.Fatalf("Failed to truncate file: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("Failed to seek file: %v", err) + } + longBody := strings.Repeat("x", 600) // More than 500 chars + writeResponseBody(f, longBody) + + content, err = os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if len(content) != 0 { + t.Error("Expected no content for long response body") + } + + // Test with empty response body + if err := f.Truncate(0); err != nil { + t.Fatalf("Failed to truncate file: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("Failed to seek file: %v", err) + } + writeResponseBody(f, "") + + content, err = os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if len(content) != 0 { + t.Error("Expected no content for empty response body") + } +} From 6701beaa49f110e7852a57c4efae700e671ff9f7 Mon Sep 17 00:00:00 2001 From: WCY-dt <834421194@qq.com> Date: Fri, 26 Sep 2025 19:19:31 +0800 Subject: [PATCH 2/3] feature: implement multi-channel notification system with configurable options --- .github/workflows/deploy.yml | 20 +- README.md | 321 +++++++++++++----- README_CN.md | 162 ++++++++- cmd/ponghub/main.go | 3 +- cmd/ponghub/main_test.go | 62 +--- go.mod | 2 +- internal/notifier/default.go | 45 +++ internal/notifier/manager.go | 130 +++++++ internal/notifier/notify.go | 86 +++++ internal/notifier/services.go | 308 +++++++++++++++++ .../types/structures/configure/configure.go | 73 +++- 11 files changed, 1066 insertions(+), 146 deletions(-) create mode 100644 internal/notifier/default.go create mode 100644 internal/notifier/manager.go create mode 100644 internal/notifier/services.go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0649a212b2..b3ecce9740 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,19 +59,21 @@ jobs: git-config-name: github-pages-deploy-action git-config-email: noreply@github.com - - name: "⚠️ Notify Unavailable Services" + - name: "⚠️ Handle Service Notifications" run: | if [ -f data/notify.txt ] && [ -s data/notify.txt ]; then - echo "Unavailable services found:" + echo "Service issues detected:" cat data/notify.txt - if [ -f "./notify.sh" ]; then - echo "Running custom notification script..." - chmod +x ./notify.sh - ./notify.sh - else - echo "No custom notification script found, using default notification." + echo "Service issues have been reported through the configured notification channels." + echo "If no notifications were received, please check your notification configuration in config.yaml" + if [ "$PONGHUB_HAS_ALERTS" = "true" ]; then + echo "" + echo "🚨 DEFAULT NOTIFICATION: GitHub Actions failure triggered as notification method" + echo "This workflow is failing intentionally to alert you of service issues." + echo "To receive notifications through other channels, configure them in config.yaml" + echo "" exit 1 fi else - echo "No unavailable services." + echo "No service issues detected." fi \ No newline at end of file diff --git a/README.md b/README.md index 0854271f0b..0c95276f8d 100644 --- a/README.md +++ b/README.md @@ -50,22 +50,23 @@ PongHub is an open-source service status monitoring website designed to help use The `config.yaml` file follows this format: -| Field | Type | Description | Required | Notes | -|-------------------------------------|---------|----------------------------------------------------------|----------|-----------------------------------------------| -| `display_num` | Integer | Number of services displayed on the homepage | ✖️ | Default is 72 services | -| `timeout` | Integer | Timeout for each request in seconds | ✖️ | Units are seconds, default is 5 seconds | -| `max_retry_times` | Integer | Number of retries on request failure | ✖️ | Default is 2 retries | -| `max_log_days` | Integer | Number of days to retain logs | ✖️ | Default is 3 days | -| `cert_notify_days` | Integer | Days before SSL certificate expiration to notify | ✖️ | Default is 7 days | -| `services` | Array | List of services to monitor | ✔️ | | -| `services.name` | String | Name of the service | ✔️ | | -| `services.endpoints` | Array | List of endpoints to check for the service | ✔️ | | | -| `services.endpoints.url` | String | URL to request | ✔️ | | -| `services.endpoints.method` | String | HTTP method for the request | ✖️ | Supports `GET`/`POST`/`PUT`, default is `GET` | -| `services.endpoints.headers` | Object | Request headers | ✖️ | Key-value | -| `services.endpoints.body` | String | Request body content | ✖️ | Used only for `POST`/`PUT` requests | -| `services.endpoints.status_code` | Integer | Expected HTTP status code in response (default is `200`) | ✖️ | Default is `200` | -| `services.endpoints.response_regex` | String | Regex to match the response body content | ✖️ | | +| Field | Type | Description | Required | Notes | +|-------------------------------------|---------|----------------------------------------------------------|----------|---------------------------------------------------| +| `display_num` | Integer | Number of services displayed on the homepage | ✖️ | Default is 72 services | +| `timeout` | Integer | Timeout for each request in seconds | ✖️ | Units are seconds, default is 5 seconds | +| `max_retry_times` | Integer | Number of retries on request failure | ✖️ | Default is 2 retries | +| `max_log_days` | Integer | Number of days to retain logs | ✖️ | Default is 3 days | +| `cert_notify_days` | Integer | Days before SSL certificate expiration to notify | ✖️ | Default is 7 days | +| `services` | Array | List of services to monitor | ✔️ | | +| `services.name` | String | Name of the service | ✔️ | | +| `services.endpoints` | Array | List of endpoints to check for the service | ✔️ | | +| `services.endpoints.url` | String | URL to request | ✔️ | | +| `services.endpoints.method` | String | HTTP method for the request | ✖️ | Supports `GET`/`POST`/`PUT`, default is `GET` | +| `services.endpoints.headers` | Object | Request headers | ✖️ | Key-value pairs, supports custom headers | +| `services.endpoints.body` | String | Request body content | ✖️ | Used only for `POST`/`PUT` requests | +| `services.endpoints.status_code` | Integer | Expected HTTP status code in response (default is `200`) | ✖️ | Default is `200` | +| `services.endpoints.response_regex` | String | Regex to match the response body content | ✖️ | | +| `notifications` | Object | Notification configuration | ✖️ | See [Custom Notifications](#custom-notifications) | Here is an example configuration file: @@ -100,20 +101,19 @@ services: ponghub now supports powerful parameterized configuration functionality, allowing the use of various types of dynamic variables in configuration files. These variables are generated and resolved in real-time during program execution.
-Click and expand to see supported parameter types +Click to expand and view supported parameter types
#### 📅 Date and Time Parameters - -Use the `{{%format}}` format to define date and time parameters: +Use `{{%format}}` format to define date and time parameters: - `{{%Y-%m-%d}}` - Current date, format: 2006-01-02 (e.g., 2025-09-22) - `{{%H:%M:%S}}` - Current time, format: 15:04:05 (e.g., 17:30:45) - `{{%s}}` - Unix timestamp (e.g., 1727859600) - `{{%Y}}` - Current year (e.g., 2025) - `{{%m}}` - Current month, format: 01-12 -- `{{%d}}` - Current day, format: 01-31 +- `{{%d}}` - Current date, format: 01-31 - `{{%H}}` - Current hour, format: 00-23 - `{{%M}}` - Current minute, format: 00-59 - `{{%S}}` - Current second, format: 00-59 @@ -124,124 +124,124 @@ Use the `{{%format}}` format to define date and time parameters: #### 🎲 Random Number Parameters -- `{{rand}}` - Generates a random number in the range 0–1000000 -- `{{rand_int}}` - Generates a large-range random integer -- `{{rand(min,max)}}` - Generates a random number within a specified range - - Example: `{{rand(1,100)}}` - Generates a random number between 1 and 100 - - Example: `{{rand(1000,9999)}}` - Generates a 4-digit random number +- `{{rand}}` - Generate random number in range 0-1000000 +- `{{rand_int}}` - Generate large range random integer +- `{{rand(min,max)}}` - Generate random number in specified range + - Example: `{{rand(1,100)}}` - Generate random number between 1-100 + - Example: `{{rand(1000,9999)}}` - Generate 4-digit random number #### 🔤 Random String Parameters -- `{{rand_str}}` - Generates an 8-character random string (letters + numbers) -- `{{rand_str(length)}}` - Generates a random string of specified length - - Example: `{{rand_str(16)}}` - Generates a 16-character random string -- `{{rand_str_secure}}` - Generates a 16-character cryptographically secure random string -- `{{rand_hex(length)}}` - Generates a random hexadecimal string of specified length - - Example: `{{rand_hex(8)}}` - Generates an 8-character hexadecimal string - - Example: `{{rand_hex(32)}}` - Generates a 32-character hexadecimal string +- `{{rand_str}}` - Generate 8-character random string (letters + numbers) +- `{{rand_str(length)}}` - Generate random string of specified length + - Example: `{{rand_str(16)}}` - Generate 16-character random string +- `{{rand_str_secure}}` - Generate 16-character cryptographically secure random string +- `{{rand_hex(length)}}` - Generate hexadecimal random string of specified length + - Example: `{{rand_hex(8)}}` - Generate 8-character hexadecimal string + - Example: `{{rand_hex(32)}}` - Generate 32-character hexadecimal string #### 🆔 UUID Parameters -- `{{uuid}}` - Generates a standard UUID (with hyphens) +- `{{uuid}}` - Generate standard UUID (with hyphens) - Example: `bf3655f7-8a93-4822-a458-2913a6fe4722` -- `{{uuid_short}}` - Generates a short UUID (without hyphens) +- `{{uuid_short}}` - Generate short UUID (without hyphens) - Example: `14d44b7334014484bb81b015fb2401bf` #### 🌍 Environment Variable Parameters -- `{{env(variable_name)}}` - Reads the value of an environment variable - - Example: `{{env(API_KEY)}}` - Reads the API_KEY environment variable - - Example: `{{env(VERSION)}}` - Reads the VERSION environment variable - - If the environment variable does not exist, returns an empty string +- `{{env(variable_name)}}` - Read environment variable value + - Example: `{{env(API_KEY)}}` - Read API_KEY environment variable + - Example: `{{env(VERSION)}}` - Read VERSION environment variable + - Returns empty string if environment variable doesn't exist -Ensure that the environment variable is set in your GitHub repository settings under "Settings" -> "Secrets and variables" -> "Actions". +Environment variables can be set through GitHub Actions Repository Secrets -#### 📊 Serial Number and Hash Parameters +#### 📊 Sequence and Hash Parameters -- `{{seq}}` - Sequence number based on the current time (6-digit number) +- `{{seq}}` - Time-based sequence number (6 digits) - `{{seq_daily}}` - Daily sequence number (seconds since midnight) -- `{{hash_short}}` - Short hash value (6-digit hexadecimal) -- `{{hash_md5_like}}` - MD5-style long hash value (32-digit hexadecimal) +- `{{hash_short}}` - Short hash value (6-character hexadecimal) +- `{{hash_md5_like}}` - MD5-style long hash value (32-character hexadecimal) #### 🌐 Network and System Information Parameters -- `{{local_ip}}` - Gets the local IP address of the system -- `{{hostname}}` - Gets the hostname of the system -- `{{user_agent}}` - Generates a random User-Agent string for HTTP requests -- `{{http_method}}` - Generates a random HTTP method (GET, POST, PUT, DELETE, etc.) +- `{{local_ip}}` - Get system local IP address +- `{{hostname}}` - Get system hostname +- `{{user_agent}}` - Generate random User-Agent string +- `{{http_method}}` - Generate random HTTP method (GET, POST, PUT, DELETE, etc.) #### 🔐 Encoding and Decoding Parameters -- `{{base64(content)}}` - Base64 encodes the provided content - - Example: `{{base64(hello world)}}` - Encodes "hello world" to Base64 -- `{{url_encode(content)}}` - URL encodes the provided content - - Example: `{{url_encode(hello world)}}` - URL encodes "hello world" -- `{{json_escape(content)}}` - JSON escapes the provided content - - Example: `{{json_escape("test")}}` - Escapes quotes and special characters for JSON +- `{{base64(content)}}` - Base64 encode the provided content + - Example: `{{base64(hello world)}}` - Encode "hello world" to Base64 +- `{{url_encode(content)}}` - URL encode the provided content + - Example: `{{url_encode(hello world)}}` - URL encode "hello world" +- `{{json_escape(content)}}` - JSON escape the provided content + - Example: `{{json_escape("test")}}` - Escape quotes and special characters for JSON #### 🔢 Mathematical Operation Parameters -- `{{add(a,b)}}` - Adds two numbers +- `{{add(a,b)}}` - Add two numbers - Example: `{{add(10,5)}}` - Returns 15 -- `{{sub(a,b)}}` - Subtracts two numbers +- `{{sub(a,b)}}` - Subtract two numbers - Example: `{{sub(10,5)}}` - Returns 5 -- `{{mul(a,b)}}` - Multiplies two numbers +- `{{mul(a,b)}}` - Multiply two numbers - Example: `{{mul(10,5)}}` - Returns 50 -- `{{div(a,b)}}` - Divides two numbers +- `{{div(a,b)}}` - Divide two numbers - Example: `{{div(10,5)}}` - Returns 2 #### 📝 Text Processing Parameters -- `{{upper(text)}}` - Converts text to uppercase +- `{{upper(text)}}` - Convert text to uppercase - Example: `{{upper(hello)}}` - Returns "HELLO" -- `{{lower(text)}}` - Converts text to lowercase +- `{{lower(text)}}` - Convert text to lowercase - Example: `{{lower(HELLO)}}` - Returns "hello" -- `{{reverse(text)}}` - Reverses the text +- `{{reverse(text)}}` - Reverse text - Example: `{{reverse(hello)}}` - Returns "olleh" -- `{{substr(text,start,length)}}` - Extracts substring from text +- `{{substr(text,start,length)}}` - Extract substring from text - Example: `{{substr(hello world,0,5)}}` - Returns "hello" #### 🎨 Color Generation Parameters -- `{{color_hex}}` - Generates a random hexadecimal color code +- `{{color_hex}}` - Generate random hexadecimal color code - Example: `#FF5733` -- `{{color_rgb}}` - Generates a random RGB color value +- `{{color_rgb}}` - Generate random RGB color value - Example: `rgb(255, 87, 51)` -- `{{color_hsl}}` - Generates a random HSL color value +- `{{color_hsl}}` - Generate random HSL color value - Example: `hsl(120, 50%, 75%)` #### 📁 File and MIME Type Parameters -- `{{mime_type}}` - Generates a random MIME type +- `{{mime_type}}` - Generate random MIME type - Example: `application/json`, `image/png`, `text/html` -- `{{file_ext}}` - Generates a random file extension +- `{{file_ext}}` - Generate random file extension - Example: `.jpg`, `.pdf`, `.txt` #### 👤 Fake Data Generation Parameters -- `{{fake_email}}` - Generates a realistic fake email address +- `{{fake_email}}` - Generate realistic fake email address - Example: `john.smith@example.com` -- `{{fake_phone}}` - Generates a fake phone number +- `{{fake_phone}}` - Generate fake phone number - Example: `+1-555-0123` -- `{{fake_name}}` - Generates a fake person name +- `{{fake_name}}` - Generate fake person name - Example: `John Smith` -- `{{fake_domain}}` - Generates a fake domain name +- `{{fake_domain}}` - Generate fake domain name - Example: `example-site.com` #### ⏰ Time Calculation Parameters -- `{{time_add(duration)}}` - Adds duration to current time - - Example: `{{time_add(1h)}}` - Adds 1 hour to current time - - Example: `{{time_add(30m)}}` - Adds 30 minutes to current time +- `{{time_add(duration)}}` - Add specified duration to current time + - Example: `{{time_add(1h)}}` - Add 1 hour to current time + - Example: `{{time_add(30m)}}` - Add 30 minutes to current time - Supported units: s (seconds), m (minutes), h (hours), d (days) -- `{{time_sub(duration)}}` - Subtracts duration from current time - - Example: `{{time_sub(1d)}}` - Subtracts 1 day from current time - - Example: `{{time_sub(2h30m)}}` - Subtracts 2 hours and 30 minutes +- `{{time_sub(duration)}}` - Subtract specified duration from current time + - Example: `{{time_sub(1d)}}` - Subtract 1 day from current time + - Example: `{{time_sub(2h30m)}}` - Subtract 2 hours 30 minutes from current time
-Below is an example configuration file: +Here is an example configuration file: ```yaml services: @@ -258,18 +258,175 @@ services: ### Custom Notifications -PongHub uses GitHub Actions for exception alert notifications by default. +PongHub now supports multiple notification methods. When services have issues or certificates are about to expire, alerts can be sent through multiple channels. + +
+Click to expand and view supported notification types + +
+ +PongHub supports the following notification methods: + +- **Default Notification** - Notification through GitHub Actions workflow failure +- **Email Notification** - Send emails via SMTP +- **Discord** - Send to Discord channels via Webhook +- **Slack** - Send to Slack channels via Webhook +- **Telegram** - Send messages via Bot API +- **WeChat Work** - Send messages via WeChat Work group bot +- **Custom Webhook** - Send to any HTTP endpoint + +To use, add a `notifications` configuration block in your `config.yaml` file: + +```yaml +notifications: + enabled: true # Enable notification functionality + methods: # Notification methods to enable + - email + - discord + - slack + - telegram + - wechat + - webhook + + # Specific configuration for each notification method... +``` + +#### ⚙️ Default Notification + +By default, PongHub will send notifications when GitHub Actions workflows fail. + +Default notification is automatically enabled when: + +- No `notifications` field is configured +- `notifications.enabled: true` but no `methods` specified +- Explicitly configured `methods: ["default"]` + +#### 📧 Email Notification -If you need custom notifications, you can create a `notify.sh` script in the root directory. The script can read the contents of the `data/notify.txt` file and send notifications via email, SMS, or other methods. If the script uses environment variables, ensure that these variables are correctly set in the "Settings" -> "Secrets and variables" -> "Actions" section of your GitHub repository. +```yaml +email: + smtp_host: "smtp.gmail.com" # SMTP server address + smtp_port: 587 # SMTP port + from: "alerts@yourdomain.com" # Sender email + to: # Recipient list + - "admin@yourdomain.com" + - "ops@yourdomain.com" + subject: "PongHub Service Alert" # Email subject (optional) +``` + +Required environment variables: + +- `SMTP_USERNAME` - SMTP username +- `SMTP_PASSWORD` - SMTP password + +#### 💬 Discord Configuration + +```yaml +discord: + webhook_url: "https://discord.com/api/webhooks/your_webhook_id/your_webhook_token" # Leave empty to read from environment variables + username: "PongHub Bot" # Username for sending messages (optional) + avatar_url: "" # Avatar URL for sending messages (optional) +``` + +Required environment variables: -## Development +- `DISCORD_WEBHOOK_URL` - Discord Webhook URL -This project uses Makefile for local development and testing. You can run the project locally with the following command: +#### 💬 Slack Configuration + +```yaml +slack: + webhook_url: "https://hooks.slack.com/services/your/webhook/url" # Leave empty to read from environment variables + channel: "#alerts" # Channel to send messages to (optional) + username: "PongHub Bot" # Username for sending messages (optional) + icon_emoji: ":robot_face:" # Message icon (optional) +``` + +Required environment variables: + +- `SLACK_WEBHOOK_URL` - Slack Webhook URL + +#### 💬 Telegram Configuration + +```yaml +telegram: + bot_token: "your_bot_token" # Leave empty to read from environment variables + chat_id: "your_chat_id" # Leave empty to read from environment variables +``` + +Required environment variables: + +- `TELEGRAM_BOT_TOKEN` - Telegram Bot Token +- `TELEGRAM_CHAT_ID` - Telegram Chat ID + +#### 💬 WeChat Work Configuration + +```yaml +wechat: + webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key" # Leave empty to read from environment variables +``` + +Required environment variables: + +- `WECHAT_WEBHOOK_URL` - WeChat Work group bot Webhook URL + +#### 💬 Custom Webhook Configuration + +```yaml +webhook: + url: "https://your-webhook-endpoint.com/notify" # Leave empty to read from environment variables + method: "POST" # HTTP method (optional, default POST) + headers: # Custom request headers (optional) + Content-Type: "application/json" +``` + +Required environment variables: + +- `WEBHOOK_URL` - Custom Webhook URL + +
+
+ +All required environment variables can be set through GitHub Actions Repository Secrets. + +Here is an example configuration file: + +```yaml +services: + - name: "Example Service" + endpoints: + - url: "https://example.com/health" +notifications: + enabled: true + methods: + - email + - discord + email: + smtp_host: "smtp.gmail.com" + smtp_port: 587 + from: "alerts@yourdomain.com" + to: + - "admin@yourdomain.com" + - "ops@yourdomain.com" + discord: + webhook_url: "https://discord.com/api/webhooks/your_webhook_id/your_webhook_token" + username: "PongHub Bot" +``` + +## Local Development + +This project uses Makefile for local development and testing. You can run the project locally using the following command: ```bash make run ``` +The project has some test cases that can be run with the following command: + +```bash +make test +``` + ## Disclaimer -[PongHub](https://github.com/WCY-dt/ponghub) is intended for personal learning and research only. The developers are not responsible for its usage or outcomes. Do not use it for commercial purposes or illegal activities. +[PongHub](https://github.com/WCY-dt/ponghub) is for personal learning and research only. We are not responsible for the usage behavior or results of the program. Please do not use it for commercial purposes or illegal activities. diff --git a/README_CN.md b/README_CN.md index b2617fc992..7188798888 100644 --- a/README_CN.md +++ b/README_CN.md @@ -66,6 +66,7 @@ PongHub 是一个开源的服务状态监控网站,旨在帮助用户监控和 | `services.endpoints.body` | 字符串 | 请求体内容 | ✖️ | 仅在 `POST`/`PUT` 请求时使用 | | `services.endpoints.status_code` | 整数 | 响应体期望的 HTTP 状态码(默认 `200`) | ✖️ | 默认 `200` | | `services.endpoints.response_regex` | 字符串 | 响应体内容的正则表达式匹配 | ✖️ | | +| `notifications` | 对象 | 通知配置 | ✖️ | 详见 [自定义通知](#自定义通知) | 下面是一个示例配置文件: @@ -257,9 +258,160 @@ services: ### 自定义通知 -PongHub 默认利用 GitHub Actions 报错实现异常告警通知。 +PongHub 现在支持多种通知方式,当服务出现问题或证书即将过期时,可以通过多个渠道发送警报通知。 -如果需要自定义通知,可以在根目录下创建 `notify.sh` 脚本,脚本可以读取 `data/notify.txt` 文件中的内容,并通过邮件、短信或其他方式发送通知。如果脚本使用到了环境变量,请确保在 GitHub 仓库的 "Settings" -> "Secrets and variables" -> "Actions" 中正确设置这些变量。 +
+点击展开查看支持的通知类型 + +
+ +PongHub 支持以下通知方式: + +- **默认通知** - 通过GitHub Actions工作流失败进行通知 +- **邮件通知** - 通过SMTP发送邮件 +- **Discord** - 通过Webhook发送到Discord频道 +- **Slack** - 通过Webhook发送到Slack频道 +- **Telegram** - 通过Bot API发送消息 +- **企业微信** - 通过企业微信群机器人发送消息 +- **自定义Webhook** - 发送到任意HTTP端点 + +使用时,在 `config.yaml` 文件中添加 `notifications` 配置块: + +```yaml +notifications: + enabled: true # 启用通知功能 + methods: # 要启用的通知方式 + - email + - discord + - slack + - telegram + - wechat + - webhook + + # 各种通知方式的具体配置... +``` + +#### ⚙️ 默认通知 + +默认情况下,PongHub 会在 GitHub Actions 工作流失败时发送通知。 + +默认通知会在以下情况自动启用: + +- 没有配置 `notifications` 字段 +- `notifications.enabled: true` 但没有指定 `methods` +- 显式配置 `methods: ["default"]` + +#### 📧 邮件通知 + +```yaml +email: + smtp_host: "smtp.gmail.com" # SMTP服务器地址 + smtp_port: 587 # SMTP端口 + from: "alerts@yourdomain.com" # 发件人邮箱 + to: # 收件人列表 + - "admin@yourdomain.com" + - "ops@yourdomain.com" + subject: "PongHub Service Alert" # 邮件主题(可选) +``` + +所需环境变量: + +- `SMTP_USERNAME` - SMTP用户名 +- `SMTP_PASSWORD` - SMTP密码 + +#### 💬 Discord 配置 + +```yaml +discord: + webhook_url: "https://discord.com/api/webhooks/your_webhook_id/your_webhook_token" # 留空则从环境变量读取 + username: "PongHub Bot" # 发送消息的用户名(可选) + avatar_url: "" # 发送消息的头像URL(可选) +``` + +所需环境变量: + +- `DISCORD_WEBHOOK_URL` - Discord Webhook URL + +#### 💬 Slack 配置 + +```yaml +slack: + webhook_url: "https://hooks.slack.com/services/your/webhook/url" # 留空则从环境变量读取 + channel: "#alerts" # 发送消息的频道(可选) + username: "PongHub Bot" # 发送消息的用户名(可选) + icon_emoji: ":robot_face:" # 消息图标(可选) +``` + +所需环境变量: + +- `SLACK_WEBHOOK_URL` - Slack Webhook URL + +#### 💬 Telegram 配置 + +```yaml +telegram: + bot_token: "your_bot_token" # 留空则从环境变量读取 + chat_id: "your_chat_id" # 留空则从环境变量读取 +``` + +所需环境变量: + +- `TELEGRAM_BOT_TOKEN` - Telegram 机器人 Token +- `TELEGRAM_CHAT_ID` - Telegram 聊天 ID + +#### 💬 企业微信配置 + +```yaml +wechat: + webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key" # 留空则从环境变量读取 +``` + +所需环境变量: + +- `WECHAT_WEBHOOK_URL` - 企业微信群机器人 Webhook URL + +#### 💬 自定义Webhook配置 + +```yaml +webhook: + url: "https://your-webhook-endpoint.com/notify" # 留空则从环境变量读取 + method: "POST" # HTTP方法(可选,默认POST) + headers: # 自定义请求头(可选) + Content-Type: "application/json" +``` + +所需环境变量: + +- `WEBHOOK_URL` - 自定义 Webhook URL + +
+
+ +以上所需的环境变量均可通过 GitHub Actions 的 Repository Secrets 设置。 + +下面是一个示例配置文件: + +```yaml +services: + - name: "Example Service" + endpoints: + - url: "https://example.com/health" +notifications: + enabled: true + methods: + - email + - discord + email: + smtp_host: "smtp.gmail.com" + smtp_port: 587 + from: "alerts@yourdomain.com" + to: + - "admin@yourdomain.com" + - "ops@yourdomain.com" + discord: + webhook_url: "https://discord.com/api/webhooks/your_webhook_id/your_webhook_token" + username: "PongHub Bot" +``` ## 本地开发 @@ -269,6 +421,12 @@ PongHub 默认利用 GitHub Actions 报错实现异常告警通知。 make run ``` +项目有一些测试用例,可以通过以下命令运行测试: + +```bash +make test +``` + ## 免责声明 [PongHub](https://github.com/WCY-dt/ponghub) 仅用于个人学习和研究,不对程序的使用行为或结果负责。请勿将其用于商业用途或非法活动。 diff --git a/cmd/ponghub/main.go b/cmd/ponghub/main.go index 080f319c42..7eb0494b7f 100644 --- a/cmd/ponghub/main.go +++ b/cmd/ponghub/main.go @@ -21,8 +21,9 @@ func main() { // check services based on the configuration checkResult := checker.CheckServices(cfg) - // write notifications based on the check results + // notify the result notifier.WriteNotifications(checkResult, cfg.CertNotifyDays) + notifier.SendNotifications(checkResult, cfg.CertNotifyDays, cfg.Notifications) // get and write log results logResult, err := logger.GetLog(checkResult, cfg.MaxLogDays, default_config.GetLogPath()) diff --git a/cmd/ponghub/main_test.go b/cmd/ponghub/main_test.go index 5eda6fb3c7..2fbd9b3230 100644 --- a/cmd/ponghub/main_test.go +++ b/cmd/ponghub/main_test.go @@ -15,25 +15,29 @@ import ( "github.com/wcy-dt/ponghub/internal/types/types/default_config" ) -// TestMain_append tests the main functionality when appending to an existing log file. -func TestMain_append(t *testing.T) { +// runMainFunctionality runs the main functionality for testing purposes. +// If copyExistingLog is true, it copies the existing log file to a temporary location. +func runMainFunctionality(copyExistingLog bool) { // load the default configuration cfg, err := configure.ReadConfigs(default_config.GetConfigPath()) if err != nil { log.Fatalln("Error loading config at", default_config.GetConfigPath(), ":", err) } - // copy log file to a temporary location for testing - logPath := default_config.GetLogPath() - if err := copyLogFile(logPath, tmpLogPath); err != nil { - log.Fatalln("Error copying log file:", err) + // copy log file to a temporary location for testing (only if copyExistingLog is true) + if copyExistingLog { + logPath := default_config.GetLogPath() + if err := copyLogFile(logPath, tmpLogPath); err != nil { + log.Fatalln("Error copying log file:", err) + } } // check services based on the configuration checkResult := checker.CheckServices(cfg) - // write notifications based on the check results + // notify the result notifier.WriteNotifications(checkResult, cfg.CertNotifyDays) + notifier.SendNotifications(checkResult, cfg.CertNotifyDays, cfg.Notifications) // get and write log results logResult, err := logger.GetLog(checkResult, cfg.MaxLogDays, tmpLogPath) @@ -63,46 +67,14 @@ func TestMain_append(t *testing.T) { } } +// TestMain_append tests the main functionality when appending to an existing log file. +func TestMain_append(t *testing.T) { + runMainFunctionality(true) +} + // TestMain_new tests the main functionality when creating a new log file. func TestMain_new(t *testing.T) { - // load the default configuration - cfg, err := configure.ReadConfigs(default_config.GetConfigPath()) - if err != nil { - log.Fatalln("Error loading config at", default_config.GetConfigPath(), ":", err) - } - - // check services based on the configuration - checkResult := checker.CheckServices(cfg) - - // write notifications based on the check results - notifier.WriteNotifications(checkResult, cfg.CertNotifyDays) - - // get and write log results - logResult, err := logger.GetLog(checkResult, cfg.MaxLogDays, tmpLogPath) - if err != nil { - log.Fatalln("Error outputting checkResult:", err) - } - if err := logger.WriteLog(logResult, tmpLogPath); err != nil { - log.Fatalln("Error writing logs to", tmpLogPath, ":", err) - } else { - log.Println("Logs written to", tmpLogPath) - } - - // generate the report based on the checkResult - reportResult, err := reporter.GetReport(checkResult, tmpLogPath, cfg) - if err != nil { - log.Fatalln("Error generating report data:", err) - } - if err := reporter.WriteReport(reportResult, default_config.GetReportPath(), cfg.DisplayNum); err != nil { - log.Fatalln("Error generating report:", err) - } else { - log.Println("Report generated at", default_config.GetReportPath()) - } - - // Remove the temporary log file after tests - if err := os.Remove(tmpLogPath); err != nil { - log.Println("Error removing temporary log file:", err) - } + runMainFunctionality(false) } // copyLogFile copies the log file from srcPath to dstPath. diff --git a/go.mod b/go.mod index c042201e56..5ae431287c 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,4 @@ go 1.24.5 require gopkg.in/yaml.v3 v3.0.1 -require github.com/google/uuid v1.6.0 // indirect +require github.com/google/uuid v1.6.0 diff --git a/internal/notifier/default.go b/internal/notifier/default.go new file mode 100644 index 0000000000..55710775e1 --- /dev/null +++ b/internal/notifier/default.go @@ -0,0 +1,45 @@ +package notifier + +import ( + "fmt" + "log" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// DefaultNotifier implements the NotificationService interface for default GitHub Actions notifications +type DefaultNotifier struct { + config *configure.DefaultConfig +} + +// NewDefaultNotifier creates a new default notifier +func NewDefaultNotifier(config *configure.DefaultConfig) *DefaultNotifier { + return &DefaultNotifier{ + config: config, + } +} + +// Send implements the NotificationService interface +// For default notifications, we write to stderr and set an exit flag +func (d *DefaultNotifier) Send(title, message string) error { + if d.config == nil { + return fmt.Errorf("default notifier config is nil") + } + + log.Println("🚨 DEFAULT NOTIFICATION TRIGGERED 🚨") + log.Printf("Title: %s", title) + log.Printf("Message:\n%s", message) + + // Write to stderr for GitHub Actions to capture + _, _ = fmt.Fprintf(os.Stderr, "\n=== PongHub Alert ===\n") + _, _ = fmt.Fprintf(os.Stderr, "%s\n\n", title) + _, _ = fmt.Fprintf(os.Stderr, "%s\n", message) + _, _ = fmt.Fprintf(os.Stderr, "=====================\n\n") + + // Set environment variable to indicate failure should occur + _ = os.Setenv("PONGHUB_HAS_ALERTS", "true") + + log.Println("Default notification sent - GitHub Actions will be notified of service issues") + return nil +} diff --git a/internal/notifier/manager.go b/internal/notifier/manager.go new file mode 100644 index 0000000000..31a826180b --- /dev/null +++ b/internal/notifier/manager.go @@ -0,0 +1,130 @@ +package notifier + +import ( + "fmt" + "log" + "strings" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// NotificationManager manages multiple notification services +type NotificationManager struct { + services []NotificationService + config *configure.NotificationConfig +} + +// NewNotificationManager creates a new notification manager +func NewNotificationManager(config *configure.NotificationConfig) *NotificationManager { + manager := &NotificationManager{ + config: config, + services: make([]NotificationService, 0), + } + + // If no notification config is provided, use default method + if config == nil { + log.Println("No notification configuration found, using default GitHub Actions notification") + defaultConfig := &configure.DefaultConfig{Enabled: true} + manager.config = &configure.NotificationConfig{ + Enabled: true, + Methods: []string{"default"}, + Default: defaultConfig, + } + manager.services = append(manager.services, NewDefaultNotifier(defaultConfig)) + return manager + } + + // If notifications are disabled, return empty manager + if !config.Enabled { + return &NotificationManager{} + } + + // If no methods are specified but notifications are enabled, use default + if len(config.Methods) == 0 { + log.Println("Notifications enabled but no methods specified, using default GitHub Actions notification") + if config.Default == nil { + config.Default = &configure.DefaultConfig{Enabled: true} + } + config.Methods = []string{"default"} + manager.services = append(manager.services, NewDefaultNotifier(config.Default)) + return manager + } + + // Initialize notification services based on configured methods + for _, method := range config.Methods { + switch strings.ToLower(method) { + case "default": + if config.Default == nil { + config.Default = &configure.DefaultConfig{Enabled: true} + } + manager.services = append(manager.services, NewDefaultNotifier(config.Default)) + case "email": + if config.Email != nil { + manager.services = append(manager.services, NewEmailNotifier(config.Email)) + } + case "discord": + if config.Discord != nil { + manager.services = append(manager.services, NewDiscordNotifier(config.Discord)) + } + case "slack": + if config.Slack != nil { + manager.services = append(manager.services, NewSlackNotifier(config.Slack)) + } + case "telegram": + if config.Telegram != nil { + manager.services = append(manager.services, NewTelegramNotifier(config.Telegram)) + } + case "wechat": + if config.WeChat != nil { + manager.services = append(manager.services, NewWeChatNotifier(config.WeChat)) + } + case "webhook": + if config.Webhook != nil { + manager.services = append(manager.services, NewWebhookNotifier(config.Webhook)) + } + default: + log.Printf("Unknown notification method: %s", method) + } + } + + return manager +} + +// SendNotification sends notification through all configured services +func (nm *NotificationManager) SendNotification(title, message string) { + if nm.config == nil || !nm.config.Enabled || len(nm.services) == 0 { + log.Println("Notifications are disabled or no services configured") + return + } + + log.Printf("Sending notifications through %d service(s)", len(nm.services)) + + var failedServices []string + for i, service := range nm.services { + if err := service.Send(title, message); err != nil { + serviceName := nm.getServiceName(i) + log.Printf("Failed to send notification via %s: %v", serviceName, err) + failedServices = append(failedServices, serviceName) + } else { + serviceName := nm.getServiceName(i) + log.Printf("Successfully sent notification via %s", serviceName) + } + } + + if len(failedServices) > 0 { + log.Printf("Failed to send notifications via: %s", strings.Join(failedServices, ", ")) + } +} + +// getServiceName returns the name of the service at the given index +func (nm *NotificationManager) getServiceName(index int) string { + if index < len(nm.config.Methods) { + return nm.config.Methods[index] + } + return fmt.Sprintf("service_%d", index) +} + +// IsEnabled returns whether notifications are enabled +func (nm *NotificationManager) IsEnabled() bool { + return nm.config != nil && nm.config.Enabled && len(nm.services) > 0 +} diff --git a/internal/notifier/notify.go b/internal/notifier/notify.go index 95735087cc..198a573f06 100644 --- a/internal/notifier/notify.go +++ b/internal/notifier/notify.go @@ -8,6 +8,7 @@ import ( "time" "github.com/wcy-dt/ponghub/internal/types/structures/checker" + "github.com/wcy-dt/ponghub/internal/types/structures/configure" "github.com/wcy-dt/ponghub/internal/types/types/chk_result" "github.com/wcy-dt/ponghub/internal/types/types/default_config" ) @@ -41,6 +42,91 @@ func WriteNotifications(checkResult []checker.Service, certNotifyDays int) { writeNotificationReport(f, statusNoneEndpoints, certProblemEndpoints) } +// SendNotifications sends notifications through various channels using the notification manager +func SendNotifications(checkResult []checker.Service, certNotifyDays int, notificationConfig *configure.NotificationConfig) { + statusNoneEndpoints := collectUnavailableEndpoints(checkResult) + certProblemEndpoints := collectCertProblemEndpoints(checkResult, certNotifyDays) + + if len(statusNoneEndpoints) == 0 && len(certProblemEndpoints) == 0 { + log.Println("No service issues found, skipping notifications") + return + } + + // Create notification manager + manager := NewNotificationManager(notificationConfig) + if !manager.IsEnabled() { + log.Println("Notification manager is not enabled or no services configured") + return + } + + // Generate notification content + title := "🚨 PongHub Service Status Alert" + message := generateNotificationMessage(statusNoneEndpoints, certProblemEndpoints) + + // Send notifications + manager.SendNotification(title, message) +} + +// generateNotificationMessage creates a formatted message for notifications +func generateNotificationMessage(statusNoneEndpoints, certProblemEndpoints map[string][]checker.Endpoint) string { + var message strings.Builder + + currentTime := time.Now().Format("2006-01-02 15:04:05") + message.WriteString(fmt.Sprintf("Generated at: %s\n\n", currentTime)) + + // Add unavailable services section + if len(statusNoneEndpoints) > 0 { + message.WriteString("🔴 UNAVAILABLE SERVICES:\n") + message.WriteString(strings.Repeat("=", 30) + "\n") + + for serviceName, endpoints := range statusNoneEndpoints { + message.WriteString(fmt.Sprintf("\n📋 Service: %s\n", serviceName)) + for _, endpoint := range endpoints { + message.WriteString(fmt.Sprintf(" • URL: %s\n", endpoint.URL)) + message.WriteString(fmt.Sprintf(" Method: %s\n", endpoint.Method)) + if endpoint.StatusCode > 0 { + message.WriteString(fmt.Sprintf(" Status Code: %d\n", endpoint.StatusCode)) + } + message.WriteString(fmt.Sprintf(" Attempts: %d/%d successful\n", endpoint.SuccessNum, endpoint.AttemptNum)) + if len(endpoint.FailureDetails) > 0 { + message.WriteString(fmt.Sprintf(" Last Error: %s\n", endpoint.FailureDetails[len(endpoint.FailureDetails)-1])) + } + } + } + } + + // Add certificate issues section + if len(certProblemEndpoints) > 0 { + message.WriteString("\n🔐 CERTIFICATE ISSUES:\n") + message.WriteString(strings.Repeat("=", 30) + "\n") + + for serviceName, endpoints := range certProblemEndpoints { + message.WriteString(fmt.Sprintf("\n📋 Service: %s\n", serviceName)) + for _, endpoint := range endpoints { + message.WriteString(fmt.Sprintf(" • URL: %s\n", endpoint.URL)) + if endpoint.IsCertExpired { + message.WriteString(" ❌ Certificate Status: EXPIRED\n") + } else { + message.WriteString(" ⚠️ Certificate Status: EXPIRES SOON\n") + } + message.WriteString(fmt.Sprintf(" Days Remaining: %d\n", endpoint.CertRemainingDays)) + } + } + } + + // Add summary + unavailableCount := countEndpoints(statusNoneEndpoints) + certIssueCount := countEndpoints(certProblemEndpoints) + + message.WriteString("\n📊 SUMMARY:\n") + message.WriteString(strings.Repeat("=", 30) + "\n") + message.WriteString(fmt.Sprintf("Unavailable Endpoints: %d\n", unavailableCount)) + message.WriteString(fmt.Sprintf("Certificate Issues: %d\n", certIssueCount)) + message.WriteString(fmt.Sprintf("Total Issues: %d\n", unavailableCount+certIssueCount)) + + return message.String() +} + // collectUnavailableEndpoints finds all endpoints with status NONE func collectUnavailableEndpoints(checkResult []checker.Service) map[string][]checker.Endpoint { statusNoneEndpoints := make(map[string][]checker.Endpoint) diff --git a/internal/notifier/services.go b/internal/notifier/services.go new file mode 100644 index 0000000000..80e7ee680e --- /dev/null +++ b/internal/notifier/services.go @@ -0,0 +1,308 @@ +package notifier + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/smtp" + "os" + "time" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// NotificationService defines the interface for notification services +type NotificationService interface { + Send(title, message string) error +} + +// EmailNotifier implements email notifications +type EmailNotifier struct { + config *configure.EmailConfig +} + +// NewEmailNotifier creates a new email notifier +func NewEmailNotifier(config *configure.EmailConfig) *EmailNotifier { + return &EmailNotifier{config: config} +} + +// Send sends an email notification +func (e *EmailNotifier) Send(title, message string) error { + // Get SMTP credentials from environment variables + username := os.Getenv("SMTP_USERNAME") + password := os.Getenv("SMTP_PASSWORD") + + if username == "" || password == "" { + return fmt.Errorf("SMTP credentials not found in environment variables") + } + + auth := smtp.PlainAuth("", username, password, e.config.SMTPHost) + + subject := title + if e.config.Subject != "" { + subject = e.config.Subject + } + + body := fmt.Sprintf("Subject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", subject, message) + + addr := fmt.Sprintf("%s:%d", e.config.SMTPHost, e.config.SMTPPort) + return smtp.SendMail(addr, auth, e.config.From, e.config.To, []byte(body)) +} + +// DiscordNotifier implements Discord webhook notifications +type DiscordNotifier struct { + config *configure.DiscordConfig +} + +// NewDiscordNotifier creates a new Discord notifier +func NewDiscordNotifier(config *configure.DiscordConfig) *DiscordNotifier { + return &DiscordNotifier{config: config} +} + +// Send sends a Discord webhook notification +func (d *DiscordNotifier) Send(title, message string) error { + webhookURL := d.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("DISCORD_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("Discord webhook URL not configured") + } + + payload := map[string]interface{}{ + "content": fmt.Sprintf("**%s**\n```\n%s\n```", title, message), + } + + if d.config.Username != "" { + payload["username"] = d.config.Username + } + if d.config.AvatarURL != "" { + payload["avatar_url"] = d.config.AvatarURL + } + + return sendWebhookRequest(webhookURL, payload) +} + +// SlackNotifier implements Slack webhook notifications +type SlackNotifier struct { + config *configure.SlackConfig +} + +// NewSlackNotifier creates a new Slack notifier +func NewSlackNotifier(config *configure.SlackConfig) *SlackNotifier { + return &SlackNotifier{config: config} +} + +// Send sends a Slack webhook notification +func (s *SlackNotifier) Send(title, message string) error { + webhookURL := s.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("SLACK_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("Slack webhook URL not configured") + } + + payload := map[string]interface{}{ + "text": fmt.Sprintf("*%s*\n```%s```", title, message), + } + + if s.config.Channel != "" { + payload["channel"] = s.config.Channel + } + if s.config.Username != "" { + payload["username"] = s.config.Username + } + if s.config.IconEmoji != "" { + payload["icon_emoji"] = s.config.IconEmoji + } + + return sendWebhookRequest(webhookURL, payload) +} + +// TelegramNotifier implements Telegram bot notifications +type TelegramNotifier struct { + config *configure.TelegramConfig +} + +// NewTelegramNotifier creates a new Telegram notifier +func NewTelegramNotifier(config *configure.TelegramConfig) *TelegramNotifier { + return &TelegramNotifier{config: config} +} + +// Send sends a Telegram bot notification +func (t *TelegramNotifier) Send(title, message string) error { + botToken := t.config.BotToken + if botToken == "" { + botToken = os.Getenv("TELEGRAM_BOT_TOKEN") + } + + chatID := t.config.ChatID + if chatID == "" { + chatID = os.Getenv("TELEGRAM_CHAT_ID") + } + + if botToken == "" || chatID == "" { + return fmt.Errorf("Telegram bot token or chat ID not configured") + } + + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + text := fmt.Sprintf("*%s*\n```\n%s\n```", title, message) + + payload := map[string]interface{}{ + "chat_id": chatID, + "text": text, + "parse_mode": "Markdown", + } + + return sendWebhookRequest(url, payload) +} + +// WeChatNotifier implements WeChat Work webhook notifications +type WeChatNotifier struct { + config *configure.WeChatConfig +} + +// NewWeChatNotifier creates a new WeChat notifier +func NewWeChatNotifier(config *configure.WeChatConfig) *WeChatNotifier { + return &WeChatNotifier{config: config} +} + +// Send sends a WeChat Work webhook notification +func (w *WeChatNotifier) Send(title, message string) error { + webhookURL := w.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("WECHAT_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("WeChat webhook URL not configured") + } + + payload := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{ + "content": fmt.Sprintf("%s\n%s", title, message), + }, + } + + return sendWebhookRequest(webhookURL, payload) +} + +// WebhookNotifier implements generic webhook notifications +type WebhookNotifier struct { + config *configure.WebhookConfig +} + +// NewWebhookNotifier creates a new generic webhook notifier +func NewWebhookNotifier(config *configure.WebhookConfig) *WebhookNotifier { + return &WebhookNotifier{config: config} +} + +// Send sends a generic webhook notification +func (w *WebhookNotifier) Send(title, message string) error { + url := w.config.URL + if url == "" { + url = os.Getenv("WEBHOOK_URL") + } + + if url == "" { + return fmt.Errorf("Webhook URL not configured") + } + + payload := map[string]interface{}{ + "title": title, + "message": message, + "timestamp": time.Now().Format(time.RFC3339), + } + + method := "POST" + if w.config.Method != "" { + method = w.config.Method + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %v", err) + } + + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Add custom headers + for key, value := range w.config.Headers { + req.Header.Set(key, value) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook request: %v", err) + } + defer func(Body io.ReadCloser) { + if err := Body.Close(); err != nil { + fmt.Println("Error closing response body:", err) + } + }(resp.Body) + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// sendWebhookRequest is a helper function to send JSON webhook requests +func sendWebhookRequest(url string, payload map[string]interface{}) error { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %v", err) + } + defer func(Body io.ReadCloser) { + if err := Body.Close(); err != nil { + fmt.Println("Error closing response body:", err) + } + }(resp.Body) + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/types/structures/configure/configure.go b/internal/types/structures/configure/configure.go index 2fecff69f4..cd8ed4c1ea 100644 --- a/internal/types/structures/configure/configure.go +++ b/internal/types/structures/configure/configure.go @@ -23,13 +23,74 @@ type ( ParsedResponseRegex string `yaml:"-"` } + // NotificationConfig defines notification settings + NotificationConfig struct { + Enabled bool `yaml:"enabled"` + Methods []string `yaml:"methods"` + Email *EmailConfig `yaml:"email,omitempty"` + Discord *DiscordConfig `yaml:"discord,omitempty"` + Slack *SlackConfig `yaml:"slack,omitempty"` + Telegram *TelegramConfig `yaml:"telegram,omitempty"` + WeChat *WeChatConfig `yaml:"wechat,omitempty"` + Webhook *WebhookConfig `yaml:"webhook,omitempty"` + Default *DefaultConfig `yaml:"default,omitempty"` + } + + // EmailConfig defines email notification settings + EmailConfig struct { + SMTPHost string `yaml:"smtp_host"` + SMTPPort int `yaml:"smtp_port"` + From string `yaml:"from"` + To []string `yaml:"to"` + Subject string `yaml:"subject,omitempty"` + } + + // DiscordConfig defines Discord webhook notification settings + DiscordConfig struct { + WebhookURL string `yaml:"webhook_url"` + Username string `yaml:"username,omitempty"` + AvatarURL string `yaml:"avatar_url,omitempty"` + } + + // SlackConfig defines Slack webhook notification settings + SlackConfig struct { + WebhookURL string `yaml:"webhook_url"` + Channel string `yaml:"channel,omitempty"` + Username string `yaml:"username,omitempty"` + IconEmoji string `yaml:"icon_emoji,omitempty"` + } + + // TelegramConfig defines Telegram bot notification settings + TelegramConfig struct { + BotToken string `yaml:"bot_token"` + ChatID string `yaml:"chat_id"` + } + + // WeChatConfig defines WeChat Work webhook notification settings + WeChatConfig struct { + WebhookURL string `yaml:"webhook_url"` + } + + // WebhookConfig defines generic webhook notification settings + WebhookConfig struct { + URL string `yaml:"url"` + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + } + + // DefaultConfig defines default notification settings (GitHub Actions failure) + DefaultConfig struct { + Enabled bool `yaml:"enabled"` + } + // Configure defines the overall configuration structure for the application Configure struct { - Services []Service `yaml:"services"` - Timeout int `yaml:"timeout,omitempty"` - MaxRetryTimes int `yaml:"max_retry_times,omitempty"` - MaxLogDays int `yaml:"max_log_days,omitempty"` - CertNotifyDays int `yaml:"cert_notify_days,omitempty"` - DisplayNum int `yaml:"display_num,omitempty"` + Services []Service `yaml:"services"` + Timeout int `yaml:"timeout,omitempty"` + MaxRetryTimes int `yaml:"max_retry_times,omitempty"` + MaxLogDays int `yaml:"max_log_days,omitempty"` + CertNotifyDays int `yaml:"cert_notify_days,omitempty"` + DisplayNum int `yaml:"display_num,omitempty"` + Notifications *NotificationConfig `yaml:"notifications,omitempty"` } ) From a0de691d6e9303409858b07e06f6437e2ea9ed1a Mon Sep 17 00:00:00 2001 From: WCY-dt <834421194@qq.com> Date: Fri, 26 Sep 2025 22:28:03 +0800 Subject: [PATCH 3/3] feature: implement email and Discord notification channels with secure options --- README.md | 11 +- README_CN.md | 11 +- cmd/ponghub/main_test.go | 20 +- internal/notifier/{ => channels}/default.go | 2 +- internal/notifier/channels/discord.go | 43 +++ internal/notifier/channels/email.go | 161 +++++++++ internal/notifier/channels/slack.go | 46 +++ internal/notifier/channels/telegram.go | 46 +++ internal/notifier/channels/utils.go | 50 +++ internal/notifier/channels/webhook.go | 88 +++++ internal/notifier/channels/wechat.go | 39 +++ internal/notifier/manager.go | 19 +- internal/notifier/notify.go | 5 + internal/notifier/services.go | 308 ------------------ .../types/structures/configure/configure.go | 13 +- 15 files changed, 521 insertions(+), 341 deletions(-) rename internal/notifier/{ => channels}/default.go (98%) create mode 100644 internal/notifier/channels/discord.go create mode 100644 internal/notifier/channels/email.go create mode 100644 internal/notifier/channels/slack.go create mode 100644 internal/notifier/channels/telegram.go create mode 100644 internal/notifier/channels/utils.go create mode 100644 internal/notifier/channels/webhook.go create mode 100644 internal/notifier/channels/wechat.go delete mode 100644 internal/notifier/services.go diff --git a/README.md b/README.md index 0c95276f8d..37aaea8e39 100644 --- a/README.md +++ b/README.md @@ -305,13 +305,16 @@ Default notification is automatically enabled when: ```yaml email: - smtp_host: "smtp.gmail.com" # SMTP server address - smtp_port: 587 # SMTP port - from: "alerts@yourdomain.com" # Sender email - to: # Recipient list + smtp_host: "smtp.gmail.com" # Leave empty to read from environment variables + smtp_port: 587 # SMTP port, default is 587 + from: "alerts@yourdomain.com" # Sender email address + to: # Recipient email addresses - "admin@yourdomain.com" - "ops@yourdomain.com" subject: "PongHub Service Alert" # Email subject (optional) + use_tls: true # Use TLS (optional) + use_starttls: true # Use StartSSL (optional) + skip_verify: true # Skip SSL certificate verification (optional) ``` Required environment variables: diff --git a/README_CN.md b/README_CN.md index 7188798888..ed03d8c999 100644 --- a/README_CN.md +++ b/README_CN.md @@ -305,13 +305,16 @@ notifications: ```yaml email: - smtp_host: "smtp.gmail.com" # SMTP服务器地址 - smtp_port: 587 # SMTP端口 - from: "alerts@yourdomain.com" # 发件人邮箱 - to: # 收件人列表 + smtp_host: "smtp.gmail.com" # SMTP服务器地址 + smtp_port: 587 # SMTP端口 + from: "alerts@yourdomain.com" # 发件人邮箱 + to: # 收件人列表 - "admin@yourdomain.com" - "ops@yourdomain.com" subject: "PongHub Service Alert" # 邮件主题(可选) + use_tls: true # 使用 TLS(可选) + use_starttls: true # 使用 StartTLS(可选) + skip_verify: false # 跳过证书验证(可选) ``` 所需环境变量: diff --git a/cmd/ponghub/main_test.go b/cmd/ponghub/main_test.go index 2fbd9b3230..86deafd783 100644 --- a/cmd/ponghub/main_test.go +++ b/cmd/ponghub/main_test.go @@ -15,6 +15,16 @@ import ( "github.com/wcy-dt/ponghub/internal/types/types/default_config" ) +// TestMain_append tests the main functionality when appending to an existing log file. +func TestMain_append(t *testing.T) { + runMainFunctionality(true) +} + +// TestMain_new tests the main functionality when creating a new log file. +func TestMain_new(t *testing.T) { + runMainFunctionality(false) +} + // runMainFunctionality runs the main functionality for testing purposes. // If copyExistingLog is true, it copies the existing log file to a temporary location. func runMainFunctionality(copyExistingLog bool) { @@ -67,16 +77,6 @@ func runMainFunctionality(copyExistingLog bool) { } } -// TestMain_append tests the main functionality when appending to an existing log file. -func TestMain_append(t *testing.T) { - runMainFunctionality(true) -} - -// TestMain_new tests the main functionality when creating a new log file. -func TestMain_new(t *testing.T) { - runMainFunctionality(false) -} - // copyLogFile copies the log file from srcPath to dstPath. // If srcPath doesn't exist, it creates an empty JSON object file at dstPath. func copyLogFile(srcPath, dstPath string) error { diff --git a/internal/notifier/default.go b/internal/notifier/channels/default.go similarity index 98% rename from internal/notifier/default.go rename to internal/notifier/channels/default.go index 55710775e1..ab73cf5ae2 100644 --- a/internal/notifier/default.go +++ b/internal/notifier/channels/default.go @@ -1,4 +1,4 @@ -package notifier +package channels import ( "fmt" diff --git a/internal/notifier/channels/discord.go b/internal/notifier/channels/discord.go new file mode 100644 index 0000000000..3b0bc41b1c --- /dev/null +++ b/internal/notifier/channels/discord.go @@ -0,0 +1,43 @@ +package channels + +import ( + "fmt" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// DiscordNotifier implements Discord webhook notifications +type DiscordNotifier struct { + config *configure.DiscordConfig +} + +// NewDiscordNotifier creates a new Discord notifier +func NewDiscordNotifier(config *configure.DiscordConfig) *DiscordNotifier { + return &DiscordNotifier{config: config} +} + +// Send sends a Discord webhook notification +func (d *DiscordNotifier) Send(title, message string) error { + webhookURL := d.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("DISCORD_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("Discord webhook URL not configured") + } + + payload := map[string]interface{}{ + "content": fmt.Sprintf("**%s**\n```\n%s\n```", title, message), + } + + if d.config.Username != "" { + payload["username"] = d.config.Username + } + if d.config.AvatarURL != "" { + payload["avatar_url"] = d.config.AvatarURL + } + + return sendWebhookRequest(webhookURL, payload) +} diff --git a/internal/notifier/channels/email.go b/internal/notifier/channels/email.go new file mode 100644 index 0000000000..3d6e16641d --- /dev/null +++ b/internal/notifier/channels/email.go @@ -0,0 +1,161 @@ +package channels + +import ( + "crypto/tls" + "fmt" + "io" + "net/smtp" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// EmailNotifier implements email notifications +type EmailNotifier struct { + config *configure.EmailConfig +} + +// NewEmailNotifier creates a new email notifier +func NewEmailNotifier(config *configure.EmailConfig) *EmailNotifier { + return &EmailNotifier{config: config} +} + +// Send sends an email notification with secure SMTP connection +func (e *EmailNotifier) Send(title, message string) error { + // Get SMTP credentials from environment variables + username := os.Getenv("SMTP_USERNAME") + password := os.Getenv("SMTP_PASSWORD") + + if username == "" || password == "" { + return fmt.Errorf("SMTP credentials not found in environment variables") + } + + addr := fmt.Sprintf("%s:%d", e.config.SMTPHost, e.config.SMTPPort) + + // Use secure connection based on configuration + if e.config.UseTLS { + // Direct TLS connection (typically port 465) + return e.sendWithTLS(addr, username, password, title, message) + } else if e.config.UseStartTLS { + // STARTTLS connection (typically port 587) + return e.sendWithStartTLS(addr, username, password, title, message) + } else { + // Plain connection - warn about security risk + fmt.Printf("WARNING: Using plain SMTP connection without TLS. This is insecure and credentials will be sent in plain text. Consider enabling use_tls or use_starttls in your configuration.\n") + return e.sendPlain(addr, username, password, title, message) + } +} + +// sendWithTLS sends email using direct TLS connection +func (e *EmailNotifier) sendWithTLS(addr, username, password, title, message string) error { + tlsConfig := &tls.Config{ + ServerName: e.config.SMTPHost, + InsecureSkipVerify: e.config.SkipVerify, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("failed to establish TLS connection: %w", err) + } + defer func(conn *tls.Conn) { + if err := conn.Close(); err != nil { + fmt.Println("Error closing TLS connection:", err) + } + }(conn) + + client, err := smtp.NewClient(conn, e.config.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer func(client *smtp.Client) { + if err := client.Quit(); err != nil { + fmt.Println("Error quitting SMTP client:", err) + } + }(client) + + auth := smtp.PlainAuth("", username, password, e.config.SMTPHost) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + return e.sendMessage(client, title, message) +} + +// sendWithStartTLS sends email using STARTTLS +func (e *EmailNotifier) sendWithStartTLS(addr, username, password, title, message string) error { + client, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer func(client *smtp.Client) { + if err := client.Quit(); err != nil { + fmt.Println("Error quitting SMTP client:", err) + } + }(client) + + tlsConfig := &tls.Config{ + ServerName: e.config.SMTPHost, + InsecureSkipVerify: e.config.SkipVerify, + } + + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + + auth := smtp.PlainAuth("", username, password, e.config.SMTPHost) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + return e.sendMessage(client, title, message) +} + +// sendPlain sends email using plain connection (insecure) +func (e *EmailNotifier) sendPlain(addr, username, password, title, message string) error { + auth := smtp.PlainAuth("", username, password, e.config.SMTPHost) + + subject := title + if e.config.Subject != "" { + subject = e.config.Subject + } + + body := fmt.Sprintf("Subject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", subject, message) + + return smtp.SendMail(addr, auth, e.config.From, e.config.To, []byte(body)) +} + +// sendMessage sends the actual email message using the provided SMTP client +func (e *EmailNotifier) sendMessage(client *smtp.Client, title, message string) error { + if err := client.Mail(e.config.From); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + + for _, to := range e.config.To { + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient %s: %w", to, err) + } + } + + writer, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + defer func(writer io.WriteCloser) { + if err := writer.Close(); err != nil { + fmt.Println("Error closing writer:", err) + } + }(writer) + + subject := title + if e.config.Subject != "" { + subject = e.config.Subject + } + + body := fmt.Sprintf("Subject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", subject, message) + + if _, err := writer.Write([]byte(body)); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + return nil +} diff --git a/internal/notifier/channels/slack.go b/internal/notifier/channels/slack.go new file mode 100644 index 0000000000..43ff44994e --- /dev/null +++ b/internal/notifier/channels/slack.go @@ -0,0 +1,46 @@ +package channels + +import ( + "fmt" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// SlackNotifier implements Slack webhook notifications +type SlackNotifier struct { + config *configure.SlackConfig +} + +// NewSlackNotifier creates a new Slack notifier +func NewSlackNotifier(config *configure.SlackConfig) *SlackNotifier { + return &SlackNotifier{config: config} +} + +// Send sends a Slack webhook notification +func (s *SlackNotifier) Send(title, message string) error { + webhookURL := s.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("SLACK_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("Slack webhook URL not configured") + } + + payload := map[string]interface{}{ + "text": fmt.Sprintf("*%s*\n```%s```", title, message), + } + + if s.config.Channel != "" { + payload["channel"] = s.config.Channel + } + if s.config.Username != "" { + payload["username"] = s.config.Username + } + if s.config.IconEmoji != "" { + payload["icon_emoji"] = s.config.IconEmoji + } + + return sendWebhookRequest(webhookURL, payload) +} diff --git a/internal/notifier/channels/telegram.go b/internal/notifier/channels/telegram.go new file mode 100644 index 0000000000..595d85d187 --- /dev/null +++ b/internal/notifier/channels/telegram.go @@ -0,0 +1,46 @@ +package channels + +import ( + "fmt" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// TelegramNotifier implements Telegram bot notifications +type TelegramNotifier struct { + config *configure.TelegramConfig +} + +// NewTelegramNotifier creates a new Telegram notifier +func NewTelegramNotifier(config *configure.TelegramConfig) *TelegramNotifier { + return &TelegramNotifier{config: config} +} + +// Send sends a Telegram bot notification +func (t *TelegramNotifier) Send(title, message string) error { + botToken := t.config.BotToken + if botToken == "" { + botToken = os.Getenv("TELEGRAM_BOT_TOKEN") + } + + chatID := t.config.ChatID + if chatID == "" { + chatID = os.Getenv("TELEGRAM_CHAT_ID") + } + + if botToken == "" || chatID == "" { + return fmt.Errorf("Telegram bot token or chat ID not configured") + } + + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + text := fmt.Sprintf("*%s*\n```\n%s\n```", title, message) + + payload := map[string]interface{}{ + "chat_id": chatID, + "text": text, + "parse_mode": "Markdown", + } + + return sendWebhookRequest(url, payload) +} diff --git a/internal/notifier/channels/utils.go b/internal/notifier/channels/utils.go new file mode 100644 index 0000000000..523de315ef --- /dev/null +++ b/internal/notifier/channels/utils.go @@ -0,0 +1,50 @@ +package channels + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// sendWebhookRequest is a helper function to send JSON webhook requests +func sendWebhookRequest(url string, payload map[string]interface{}) error { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %v", err) + } + defer func(Body io.ReadCloser) { + if err := Body.Close(); err != nil { + fmt.Println("Error closing response body:", err) + } + }(resp.Body) + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/notifier/channels/webhook.go b/internal/notifier/channels/webhook.go new file mode 100644 index 0000000000..6460dce8bb --- /dev/null +++ b/internal/notifier/channels/webhook.go @@ -0,0 +1,88 @@ +package channels + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// WebhookNotifier implements generic webhook notifications +type WebhookNotifier struct { + config *configure.WebhookConfig +} + +// NewWebhookNotifier creates a new generic webhook notifier +func NewWebhookNotifier(config *configure.WebhookConfig) *WebhookNotifier { + return &WebhookNotifier{config: config} +} + +// Send sends a generic webhook notification +func (w *WebhookNotifier) Send(title, message string) error { + url := w.config.URL + if url == "" { + url = os.Getenv("WEBHOOK_URL") + } + + if url == "" { + return fmt.Errorf("Webhook URL not configured") + } + + payload := map[string]interface{}{ + "title": title, + "message": message, + "timestamp": time.Now().Format(time.RFC3339), + } + + method := "POST" + if w.config.Method != "" { + method = w.config.Method + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %v", err) + } + + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Add custom headers + for key, value := range w.config.Headers { + req.Header.Set(key, value) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook request: %v", err) + } + defer func(Body io.ReadCloser) { + if err := Body.Close(); err != nil { + fmt.Println("Error closing response body:", err) + } + }(resp.Body) + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/notifier/channels/wechat.go b/internal/notifier/channels/wechat.go new file mode 100644 index 0000000000..b9ed6a82c1 --- /dev/null +++ b/internal/notifier/channels/wechat.go @@ -0,0 +1,39 @@ +package channels + +import ( + "fmt" + "os" + + "github.com/wcy-dt/ponghub/internal/types/structures/configure" +) + +// WeChatNotifier implements WeChat Work webhook notifications +type WeChatNotifier struct { + config *configure.WeChatConfig +} + +// NewWeChatNotifier creates a new WeChat notifier +func NewWeChatNotifier(config *configure.WeChatConfig) *WeChatNotifier { + return &WeChatNotifier{config: config} +} + +// Send sends a WeChat Work webhook notification +func (w *WeChatNotifier) Send(title, message string) error { + webhookURL := w.config.WebhookURL + if webhookURL == "" { + webhookURL = os.Getenv("WECHAT_WEBHOOK_URL") + } + + if webhookURL == "" { + return fmt.Errorf("WeChat webhook URL not configured") + } + + payload := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{ + "content": fmt.Sprintf("%s\n%s", title, message), + }, + } + + return sendWebhookRequest(webhookURL, payload) +} diff --git a/internal/notifier/manager.go b/internal/notifier/manager.go index 31a826180b..37028726e9 100644 --- a/internal/notifier/manager.go +++ b/internal/notifier/manager.go @@ -5,6 +5,7 @@ import ( "log" "strings" + "github.com/wcy-dt/ponghub/internal/notifier/channels" "github.com/wcy-dt/ponghub/internal/types/structures/configure" ) @@ -30,7 +31,7 @@ func NewNotificationManager(config *configure.NotificationConfig) *NotificationM Methods: []string{"default"}, Default: defaultConfig, } - manager.services = append(manager.services, NewDefaultNotifier(defaultConfig)) + manager.services = append(manager.services, channels.NewDefaultNotifier(defaultConfig)) return manager } @@ -46,7 +47,7 @@ func NewNotificationManager(config *configure.NotificationConfig) *NotificationM config.Default = &configure.DefaultConfig{Enabled: true} } config.Methods = []string{"default"} - manager.services = append(manager.services, NewDefaultNotifier(config.Default)) + manager.services = append(manager.services, channels.NewDefaultNotifier(config.Default)) return manager } @@ -57,30 +58,30 @@ func NewNotificationManager(config *configure.NotificationConfig) *NotificationM if config.Default == nil { config.Default = &configure.DefaultConfig{Enabled: true} } - manager.services = append(manager.services, NewDefaultNotifier(config.Default)) + manager.services = append(manager.services, channels.NewDefaultNotifier(config.Default)) case "email": if config.Email != nil { - manager.services = append(manager.services, NewEmailNotifier(config.Email)) + manager.services = append(manager.services, channels.NewEmailNotifier(config.Email)) } case "discord": if config.Discord != nil { - manager.services = append(manager.services, NewDiscordNotifier(config.Discord)) + manager.services = append(manager.services, channels.NewDiscordNotifier(config.Discord)) } case "slack": if config.Slack != nil { - manager.services = append(manager.services, NewSlackNotifier(config.Slack)) + manager.services = append(manager.services, channels.NewSlackNotifier(config.Slack)) } case "telegram": if config.Telegram != nil { - manager.services = append(manager.services, NewTelegramNotifier(config.Telegram)) + manager.services = append(manager.services, channels.NewTelegramNotifier(config.Telegram)) } case "wechat": if config.WeChat != nil { - manager.services = append(manager.services, NewWeChatNotifier(config.WeChat)) + manager.services = append(manager.services, channels.NewWeChatNotifier(config.WeChat)) } case "webhook": if config.Webhook != nil { - manager.services = append(manager.services, NewWebhookNotifier(config.Webhook)) + manager.services = append(manager.services, channels.NewWebhookNotifier(config.Webhook)) } default: log.Printf("Unknown notification method: %s", method) diff --git a/internal/notifier/notify.go b/internal/notifier/notify.go index 198a573f06..1a43551b42 100644 --- a/internal/notifier/notify.go +++ b/internal/notifier/notify.go @@ -13,6 +13,11 @@ import ( "github.com/wcy-dt/ponghub/internal/types/types/default_config" ) +// NotificationService defines the interface for notification services +type NotificationService interface { + Send(title, message string) error +} + // WriteNotifications sends notifications based on the service check results func WriteNotifications(checkResult []checker.Service, certNotifyDays int) { statusNoneEndpoints := collectUnavailableEndpoints(checkResult) diff --git a/internal/notifier/services.go b/internal/notifier/services.go deleted file mode 100644 index 80e7ee680e..0000000000 --- a/internal/notifier/services.go +++ /dev/null @@ -1,308 +0,0 @@ -package notifier - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "net/smtp" - "os" - "time" - - "github.com/wcy-dt/ponghub/internal/types/structures/configure" -) - -// NotificationService defines the interface for notification services -type NotificationService interface { - Send(title, message string) error -} - -// EmailNotifier implements email notifications -type EmailNotifier struct { - config *configure.EmailConfig -} - -// NewEmailNotifier creates a new email notifier -func NewEmailNotifier(config *configure.EmailConfig) *EmailNotifier { - return &EmailNotifier{config: config} -} - -// Send sends an email notification -func (e *EmailNotifier) Send(title, message string) error { - // Get SMTP credentials from environment variables - username := os.Getenv("SMTP_USERNAME") - password := os.Getenv("SMTP_PASSWORD") - - if username == "" || password == "" { - return fmt.Errorf("SMTP credentials not found in environment variables") - } - - auth := smtp.PlainAuth("", username, password, e.config.SMTPHost) - - subject := title - if e.config.Subject != "" { - subject = e.config.Subject - } - - body := fmt.Sprintf("Subject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", subject, message) - - addr := fmt.Sprintf("%s:%d", e.config.SMTPHost, e.config.SMTPPort) - return smtp.SendMail(addr, auth, e.config.From, e.config.To, []byte(body)) -} - -// DiscordNotifier implements Discord webhook notifications -type DiscordNotifier struct { - config *configure.DiscordConfig -} - -// NewDiscordNotifier creates a new Discord notifier -func NewDiscordNotifier(config *configure.DiscordConfig) *DiscordNotifier { - return &DiscordNotifier{config: config} -} - -// Send sends a Discord webhook notification -func (d *DiscordNotifier) Send(title, message string) error { - webhookURL := d.config.WebhookURL - if webhookURL == "" { - webhookURL = os.Getenv("DISCORD_WEBHOOK_URL") - } - - if webhookURL == "" { - return fmt.Errorf("Discord webhook URL not configured") - } - - payload := map[string]interface{}{ - "content": fmt.Sprintf("**%s**\n```\n%s\n```", title, message), - } - - if d.config.Username != "" { - payload["username"] = d.config.Username - } - if d.config.AvatarURL != "" { - payload["avatar_url"] = d.config.AvatarURL - } - - return sendWebhookRequest(webhookURL, payload) -} - -// SlackNotifier implements Slack webhook notifications -type SlackNotifier struct { - config *configure.SlackConfig -} - -// NewSlackNotifier creates a new Slack notifier -func NewSlackNotifier(config *configure.SlackConfig) *SlackNotifier { - return &SlackNotifier{config: config} -} - -// Send sends a Slack webhook notification -func (s *SlackNotifier) Send(title, message string) error { - webhookURL := s.config.WebhookURL - if webhookURL == "" { - webhookURL = os.Getenv("SLACK_WEBHOOK_URL") - } - - if webhookURL == "" { - return fmt.Errorf("Slack webhook URL not configured") - } - - payload := map[string]interface{}{ - "text": fmt.Sprintf("*%s*\n```%s```", title, message), - } - - if s.config.Channel != "" { - payload["channel"] = s.config.Channel - } - if s.config.Username != "" { - payload["username"] = s.config.Username - } - if s.config.IconEmoji != "" { - payload["icon_emoji"] = s.config.IconEmoji - } - - return sendWebhookRequest(webhookURL, payload) -} - -// TelegramNotifier implements Telegram bot notifications -type TelegramNotifier struct { - config *configure.TelegramConfig -} - -// NewTelegramNotifier creates a new Telegram notifier -func NewTelegramNotifier(config *configure.TelegramConfig) *TelegramNotifier { - return &TelegramNotifier{config: config} -} - -// Send sends a Telegram bot notification -func (t *TelegramNotifier) Send(title, message string) error { - botToken := t.config.BotToken - if botToken == "" { - botToken = os.Getenv("TELEGRAM_BOT_TOKEN") - } - - chatID := t.config.ChatID - if chatID == "" { - chatID = os.Getenv("TELEGRAM_CHAT_ID") - } - - if botToken == "" || chatID == "" { - return fmt.Errorf("Telegram bot token or chat ID not configured") - } - - url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) - text := fmt.Sprintf("*%s*\n```\n%s\n```", title, message) - - payload := map[string]interface{}{ - "chat_id": chatID, - "text": text, - "parse_mode": "Markdown", - } - - return sendWebhookRequest(url, payload) -} - -// WeChatNotifier implements WeChat Work webhook notifications -type WeChatNotifier struct { - config *configure.WeChatConfig -} - -// NewWeChatNotifier creates a new WeChat notifier -func NewWeChatNotifier(config *configure.WeChatConfig) *WeChatNotifier { - return &WeChatNotifier{config: config} -} - -// Send sends a WeChat Work webhook notification -func (w *WeChatNotifier) Send(title, message string) error { - webhookURL := w.config.WebhookURL - if webhookURL == "" { - webhookURL = os.Getenv("WECHAT_WEBHOOK_URL") - } - - if webhookURL == "" { - return fmt.Errorf("WeChat webhook URL not configured") - } - - payload := map[string]interface{}{ - "msgtype": "text", - "text": map[string]string{ - "content": fmt.Sprintf("%s\n%s", title, message), - }, - } - - return sendWebhookRequest(webhookURL, payload) -} - -// WebhookNotifier implements generic webhook notifications -type WebhookNotifier struct { - config *configure.WebhookConfig -} - -// NewWebhookNotifier creates a new generic webhook notifier -func NewWebhookNotifier(config *configure.WebhookConfig) *WebhookNotifier { - return &WebhookNotifier{config: config} -} - -// Send sends a generic webhook notification -func (w *WebhookNotifier) Send(title, message string) error { - url := w.config.URL - if url == "" { - url = os.Getenv("WEBHOOK_URL") - } - - if url == "" { - return fmt.Errorf("Webhook URL not configured") - } - - payload := map[string]interface{}{ - "title": title, - "message": message, - "timestamp": time.Now().Format(time.RFC3339), - } - - method := "POST" - if w.config.Method != "" { - method = w.config.Method - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal webhook payload: %v", err) - } - - req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload)) - if err != nil { - return fmt.Errorf("failed to create webhook request: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - - // Add custom headers - for key, value := range w.config.Headers { - req.Header.Set(key, value) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send webhook request: %v", err) - } - defer func(Body io.ReadCloser) { - if err := Body.Close(); err != nil { - fmt.Println("Error closing response body:", err) - } - }(resp.Body) - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body)) - } - - return nil -} - -// sendWebhookRequest is a helper function to send JSON webhook requests -func sendWebhookRequest(url string, payload map[string]interface{}) error { - jsonPayload, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %v", err) - } - - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) - if err != nil { - return fmt.Errorf("failed to create request: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send request: %v", err) - } - defer func(Body io.ReadCloser) { - if err := Body.Close(); err != nil { - fmt.Println("Error closing response body:", err) - } - }(resp.Body) - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) - } - - return nil -} diff --git a/internal/types/structures/configure/configure.go b/internal/types/structures/configure/configure.go index cd8ed4c1ea..3d7ed55715 100644 --- a/internal/types/structures/configure/configure.go +++ b/internal/types/structures/configure/configure.go @@ -38,11 +38,14 @@ type ( // EmailConfig defines email notification settings EmailConfig struct { - SMTPHost string `yaml:"smtp_host"` - SMTPPort int `yaml:"smtp_port"` - From string `yaml:"from"` - To []string `yaml:"to"` - Subject string `yaml:"subject,omitempty"` + SMTPHost string `yaml:"smtp_host"` + SMTPPort int `yaml:"smtp_port"` + From string `yaml:"from"` + To []string `yaml:"to"` + Subject string `yaml:"subject,omitempty"` + UseTLS bool `yaml:"use_tls,omitempty"` // Enable TLS encryption + UseStartTLS bool `yaml:"use_starttls,omitempty"` // Enable STARTTLS + SkipVerify bool `yaml:"skip_verify,omitempty"` // Skip TLS certificate verification (insecure) } // DiscordConfig defines Discord webhook notification settings