From 687619ce0d5a1c88b3449dd6b572ccdd0704d4bf Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 16 May 2025 22:17:02 +0800 Subject: [PATCH] Feat: Implement SSL notification channel selection - Added notification channel selection to the Add SSL Certificate form. - Display available Telegram notification channels. - Integrate SSL expiry and warning thresholds for notifications. - Ensure daily auto-checks and manual check button functionality. --- application/src/services/ssl/index.ts | 37 +++++ .../services/ssl/sslCertificateOperations.ts | 156 ++++++++++++++++++ .../src/services/ssl/sslCheckerService.ts | 39 +++++ .../src/services/ssl/sslCheckerUtils.ts | 52 ++++++ .../src/services/ssl/sslFetchService.ts | 89 ++++++++++ .../src/services/ssl/sslPrimaryChecker.ts | 29 ++++ .../src/services/ssl/sslStatusUtils.ts | 15 ++ application/src/services/ssl/types.ts | 74 +++++++++ application/src/services/ssl/utils.ts | 51 ++++++ 9 files changed, 542 insertions(+) create mode 100644 application/src/services/ssl/index.ts create mode 100644 application/src/services/ssl/sslCertificateOperations.ts create mode 100644 application/src/services/ssl/sslCheckerService.ts create mode 100644 application/src/services/ssl/sslCheckerUtils.ts create mode 100644 application/src/services/ssl/sslFetchService.ts create mode 100644 application/src/services/ssl/sslPrimaryChecker.ts create mode 100644 application/src/services/ssl/sslStatusUtils.ts create mode 100644 application/src/services/ssl/types.ts create mode 100644 application/src/services/ssl/utils.ts diff --git a/application/src/services/ssl/index.ts b/application/src/services/ssl/index.ts new file mode 100644 index 0000000..129df11 --- /dev/null +++ b/application/src/services/ssl/index.ts @@ -0,0 +1,37 @@ + +// Re-export all SSL-related functionality for domain SSL checking +// Use explicit re-exports to avoid naming conflicts + +// SSL Checker service +export { checkSSLCertificate, checkSSLApi } from './sslCheckerService'; + +// SSL Status utilities +export { determineSSLStatus } from './sslStatusUtils'; + +// Primary export for fetchSSLCertificates +export { fetchSSLCertificates } from './sslFetchService'; + +// Certificate operations +export { + addSSLCertificate, + checkAndUpdateCertificate, + deleteSSLCertificate, + refreshAllCertificates +} from './sslCertificateOperations'; + +// SSL-specific notification service +export { + checkAllCertificatesAndNotify, + checkCertificateAndNotify, + shouldRunDailyCheck, + sendSSLNotification +} from './notification'; + +// Export types +export type { SSLCheckerResponse, SSLCertificate, AddSSLCertificateDto, SSLNotification } from './types'; + +// Export utility functions +export { normalizeDomain, createErrorResponse } from './sslCheckerUtils'; + +// Export checking mechanisms +export { checkWithFetch } from './sslPrimaryChecker'; \ No newline at end of file diff --git a/application/src/services/ssl/sslCertificateOperations.ts b/application/src/services/ssl/sslCertificateOperations.ts new file mode 100644 index 0000000..9dd9c16 --- /dev/null +++ b/application/src/services/ssl/sslCertificateOperations.ts @@ -0,0 +1,156 @@ + +import { pb } from "@/lib/pocketbase"; +import type { AddSSLCertificateDto, SSLCertificate } from "./types"; +import { checkSSLCertificate } from "./sslCheckerService"; +import { determineSSLStatus } from "./sslStatusUtils"; +import { checkCertificateAndNotify } from "./notification"; // Import notification service +import { toast } from "sonner"; + +/** + * Add a new SSL certificate to monitor + */ +export const addSSLCertificate = async ( + certificateData: AddSSLCertificateDto +): Promise => { + try { + // First check if the SSL certificate is valid and can be fetched + const sslData = await checkSSLCertificate(certificateData.domain); + + if (!sslData || !sslData.result) { + throw new Error(`Could not fetch SSL certificate for ${certificateData.domain}`); + } + + // Prepare the data for saving to database + const data = { + domain: certificateData.domain, + issued_to: sslData.result.issued_to || certificateData.domain, + issuer_o: sslData.result.issuer_o || "", + status: determineSSLStatus( + sslData.result.days_left || 0, + certificateData.warning_threshold, + certificateData.expiry_threshold + ), + cert_sans: sslData.result.cert_sans || "", + cert_alg: sslData.result.cert_alg || "", + serial_number: sslData.result.cert_sn || "", + valid_from: sslData.result.valid_from || new Date().toISOString(), + valid_till: sslData.result.valid_till || new Date().toISOString(), + validity_days: sslData.result.validity_days || 0, + days_left: sslData.result.days_left || 0, + warning_threshold: Number(certificateData.warning_threshold) || 30, + expiry_threshold: Number(certificateData.expiry_threshold) || 7, + notification_channel: certificateData.notification_channel || "", + }; + + // Save to database + const record = await pb.collection("ssl_certificates").create(data); + + return record as unknown as SSLCertificate; + } catch (error) { + console.error("Error adding SSL certificate:", error); + throw error; + } +}; + +/** + * Check and update a specific SSL certificate + */ +export const checkAndUpdateCertificate = async ( + certificateId: string +): Promise => { + try { + // Get the certificate from database + const certificate = await pb.collection("ssl_certificates").getOne(certificateId); + + if (!certificate) { + throw new Error(`Certificate with ID ${certificateId} not found`); + } + + const typedCertificate = certificate as unknown as SSLCertificate; + const domain = typedCertificate.domain; + + // Check SSL certificate + const sslData = await checkSSLCertificate(domain); + + if (!sslData || !sslData.result) { + throw new Error(`Could not fetch SSL certificate for ${domain}`); + } + + // Update certificate data + const updateData = { + issued_to: sslData.result.issued_to || domain, + issuer_o: sslData.result.issuer_o || typedCertificate.issuer_o, + status: determineSSLStatus( + sslData.result.days_left || 0, + typedCertificate.warning_threshold, + typedCertificate.expiry_threshold + ), + cert_sans: sslData.result.cert_sans || typedCertificate.cert_sans, + cert_alg: sslData.result.cert_alg || typedCertificate.cert_alg, + serial_number: sslData.result.cert_sn || typedCertificate.serial_number, + valid_from: sslData.result.valid_from || typedCertificate.valid_from, + valid_till: sslData.result.valid_till || typedCertificate.valid_till, + validity_days: sslData.result.validity_days || typedCertificate.validity_days, + days_left: sslData.result.days_left || 0, + }; + + // Update in database + const updatedCert = await pb + .collection("ssl_certificates") + .update(certificateId, updateData); + + const updatedCertificate = updatedCert as unknown as SSLCertificate; + + // After updating, check if notification should be sent + // This will respect the Warning and Expiry Thresholds + await checkCertificateAndNotify(updatedCertificate); + + return updatedCertificate; + } catch (error) { + console.error("Error updating SSL certificate:", error); + throw error; + } +}; + +/** + * Delete an SSL certificate from monitoring + */ +export const deleteSSLCertificate = async (id: string): Promise => { + try { + await pb.collection("ssl_certificates").delete(id); + return true; + } catch (error) { + console.error("Error deleting SSL certificate:", error); + throw error; + } +}; + +/** + * Refresh all SSL certificates + */ +export const refreshAllCertificates = async (): Promise<{ success: number; failed: number }> => { + try { + const response = await pb.collection("ssl_certificates").getList(1, 100); + const certificates = response.items as unknown as SSLCertificate[]; + + console.log(`Refreshing ${certificates.length} certificates...`); + + let success = 0; + let failed = 0; + + for (const cert of certificates) { + try { + await checkAndUpdateCertificate(cert.id); + success++; + } catch (error) { + console.error(`Failed to refresh certificate ${cert.domain}:`, error); + failed++; + } + } + + return { success, failed }; + } catch (error) { + console.error("Error refreshing certificates:", error); + throw error; + } +}; \ No newline at end of file diff --git a/application/src/services/ssl/sslCheckerService.ts b/application/src/services/ssl/sslCheckerService.ts new file mode 100644 index 0000000..d970e90 --- /dev/null +++ b/application/src/services/ssl/sslCheckerService.ts @@ -0,0 +1,39 @@ + +import type { SSLCheckerResponse } from "./types"; +import { toast } from "sonner"; +import { checkWithFetch } from "./sslPrimaryChecker"; +import { normalizeDomain, createErrorResponse } from "./sslCheckerUtils"; + +/** + * Check SSL certificate for a domain + * Uses the reliable CORS proxy approach + */ +export const checkSSLCertificate = async (domain: string): Promise => { + try { + console.log(`Checking SSL certificate for domain: ${domain}`); + + // Normalize domain (remove protocol if present) + const normalizedDomain = normalizeDomain(domain); + + if (!normalizedDomain) { + throw new Error("Invalid domain provided"); + } + + // Use the working CORS proxy approach + const result = await checkWithFetch(normalizedDomain); + console.log("SSL check result:", result); + return result; + } catch (error) { + console.error("SSL check failed completely:", error); + toast.error(`SSL check failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + return createErrorResponse(domain, error); + } +}; + +/** + * Check SSL certificate using the CORS proxy + * This is kept for backward compatibility + */ +export const checkSSLApi = async (domain: string): Promise => { + return checkSSLCertificate(domain); +}; \ No newline at end of file diff --git a/application/src/services/ssl/sslCheckerUtils.ts b/application/src/services/ssl/sslCheckerUtils.ts new file mode 100644 index 0000000..4c27156 --- /dev/null +++ b/application/src/services/ssl/sslCheckerUtils.ts @@ -0,0 +1,52 @@ + +/** + * Normalize domain by removing protocol if present + */ +export const normalizeDomain = (domain: string): string => { + if (!domain) return ''; + + // Remove any protocol (http://, https://) + return domain.replace(/^(https?:\/\/)/, '').trim(); + }; + + /** + * Create error response for SSL check failures + */ + export const createErrorResponse = (domain: string, error: unknown): any => { + const errorMessage = error instanceof Error ? error.message : 'Unknown SSL check error'; + + const errorResponse = { + version: "1.0", + app: "ssl-checker", + host: domain, + response_time_sec: "0", + status: "error", + message: errorMessage, + error: errorMessage, + result: { + host: domain, + resolved_ip: domain, + issued_to: domain, + issued_o: null, + issuer_c: "Unknown", + issuer_o: "Unknown", + issuer_ou: null, + issuer_cn: "Unknown", + cert_sn: "Unknown", + cert_sha1: "", + cert_alg: "Unknown", + cert_ver: 0, + cert_sans: domain, + cert_exp: true, + cert_valid: false, + valid_from: "Unknown", + valid_till: "Unknown", + validity_days: 0, + days_left: 0, + valid_days_to_expire: 0, + hsts_header_enabled: false + } + }; + + return errorResponse; + }; \ No newline at end of file diff --git a/application/src/services/ssl/sslFetchService.ts b/application/src/services/ssl/sslFetchService.ts new file mode 100644 index 0000000..3846236 --- /dev/null +++ b/application/src/services/ssl/sslFetchService.ts @@ -0,0 +1,89 @@ + +import { pb } from "@/lib/pocketbase"; +import { SSLCertificate } from "@/types/ssl.types"; +import { toast } from "sonner"; + +/** + * Fetch SSL certificates from PocketBase + */ +export const fetchSSLCertificates = async (): Promise => { + try { + console.log("Fetching SSL certificates from PocketBase..."); + + // Using the direct API path to fetch SSL certificates + const endpoint = "/api/collections/ssl_certificates/records"; + const params = { + page: 1, + perPage: 50, + sort: "-created", + cache: Date.now() // Prevent caching by adding a timestamp + }; + + // Convert params to query string + const queryString = new URLSearchParams(params as any).toString(); + const fullEndpoint = `${endpoint}?${queryString}`; + + console.log("Fetching SSL certificates from:", fullEndpoint); + const response = await pb.send(fullEndpoint, { + method: "GET", + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + // Check if response has expected structure + if (!response || !response.items) { + console.error("Invalid response format:", response); + toast.error("Failed to fetch SSL certificates: Invalid response format"); + throw new Error("Invalid response format from PocketBase API"); + } + + console.log("Received SSL certificates:", response.items.length); + + // Map items to SSLCertificate[] type with validation + return response.items.map(item => { + const cert = item as SSLCertificate; + + // Log certificate details for debugging + console.log(`Certificate for ${cert.domain}: Issued by ${cert.issuer_o}, Days left: ${cert.days_left}`); + + // Ensure dates are valid + try { + if (cert.valid_from) new Date(cert.valid_from).toISOString(); + if (cert.valid_till) new Date(cert.valid_till).toISOString(); + if (cert.last_notified) new Date(cert.last_notified).toISOString(); + } catch (e) { + console.warn("Invalid date found in certificate", cert.id, e); + // Fix invalid dates if needed + if (cert.valid_from && isNaN(new Date(cert.valid_from).getTime())) { + cert.valid_from = new Date().toISOString(); + } + if (cert.valid_till && isNaN(new Date(cert.valid_till).getTime())) { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + cert.valid_till = futureDate.toISOString(); + } + } + + // Calculate days left if not provided + if (typeof cert.days_left !== 'number' && cert.valid_till) { + try { + const expirationDate = new Date(cert.valid_till); + const currentDate = new Date(); + const diffTime = expirationDate.getTime() - currentDate.getTime(); + cert.days_left = Math.ceil(diffTime / (1000 * 3600 * 24)); + } catch (e) { + console.warn("Error calculating days_left for certificate", cert.id, e); + cert.days_left = 0; + } + } + + return cert; + }); + } catch (error) { + console.error("Error fetching SSL certificates:", error); + toast.error("Failed to fetch SSL certificates. Please try again later."); + throw error; + } +}; \ No newline at end of file diff --git a/application/src/services/ssl/sslPrimaryChecker.ts b/application/src/services/ssl/sslPrimaryChecker.ts new file mode 100644 index 0000000..59fbddc --- /dev/null +++ b/application/src/services/ssl/sslPrimaryChecker.ts @@ -0,0 +1,29 @@ + +import { createErrorResponse } from "./sslCheckerUtils"; +import type { SSLCheckerResponse } from "./types"; + +/** + * Check SSL certificate using fetch with CORS proxy + * This is our primary method that's proven to work + */ +export const checkWithFetch = async (domain: string): Promise => { + console.log(`Checking SSL via CORS proxy for: ${domain}`); + + try { + // Use the working CORS proxy + const corsProxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(`https://ssl-checker.io/api/v1/check/${domain}`)}`; + console.log("Using CORS proxy for SSL check:", corsProxyUrl); + + const proxyResponse = await fetch(corsProxyUrl); + if (!proxyResponse.ok) { + throw new Error(`CORS proxy request failed with status: ${proxyResponse.status}`); + } + + const proxyData = await proxyResponse.json(); + console.log("CORS proxy returned SSL data:", proxyData); + return proxyData; + } catch (error) { + console.error("SSL check failed:", error); + return createErrorResponse(domain, error); + } +}; \ No newline at end of file diff --git a/application/src/services/ssl/sslStatusUtils.ts b/application/src/services/ssl/sslStatusUtils.ts new file mode 100644 index 0000000..566ca34 --- /dev/null +++ b/application/src/services/ssl/sslStatusUtils.ts @@ -0,0 +1,15 @@ + +/** + * Determine SSL certificate status based on days left + */ +export const determineSSLStatus = (daysLeft: number, warningThreshold: number, expiryThreshold: number): string => { + if (daysLeft <= 0) { + return "expired"; + } else if (daysLeft <= expiryThreshold) { + return "expiring_soon"; + } else if (daysLeft > expiryThreshold) { + return "valid"; + } else { + return "unknown"; + } + }; \ No newline at end of file diff --git a/application/src/services/ssl/types.ts b/application/src/services/ssl/types.ts new file mode 100644 index 0000000..5797aa7 --- /dev/null +++ b/application/src/services/ssl/types.ts @@ -0,0 +1,74 @@ + +// SSL Checker response type +export interface SSLCheckerResponse { + version: string; + app: string; + host: string; + response_time_sec: string; + status: string; // "ok", "error" + message?: string; + error?: string; + result: { + host: string; + resolved_ip?: string; + issued_to: string; + issued_o?: string | null; + issuer_c?: string; + issuer_o?: string | null; + issuer_ou?: string | null; + issuer_cn?: string; + cert_sn: string; + cert_sha1?: string; + cert_alg: string; + cert_ver?: number; + cert_sans: string; + cert_exp?: boolean; + cert_valid?: boolean; + valid_from?: string; + valid_till?: string; + validity_days?: number; + days_left?: number; + valid_days_to_expire?: number; + hsts_header_enabled?: boolean; + }; + } + + // SSL Certificate DTO for adding new certificates + export interface AddSSLCertificateDto { + domain: string; + warning_threshold: number; + expiry_threshold: number; + notification_channel: string; + } + + // SSL Certificate model + export interface SSLCertificate { + id: string; + domain: string; + issued_to: string; + issuer_o: string; + status: string; + cert_sans?: string; + cert_alg?: string; + serial_number?: number | string; + valid_from: string; + valid_till: string; + validity_days: number; + days_left: number; + valid_days_to_expire?: number; + warning_threshold: number; + expiry_threshold: number; + notification_channel: string; + last_notified?: string; + created?: string; + updated?: string; + } + + // SSL specific notification types + export interface SSLNotification { + certificateId: string; + domain: string; + message: string; + isCritical: boolean; + timestamp: string; + } \ No newline at end of file diff --git a/application/src/services/ssl/utils.ts b/application/src/services/ssl/utils.ts new file mode 100644 index 0000000..dc243b3 --- /dev/null +++ b/application/src/services/ssl/utils.ts @@ -0,0 +1,51 @@ + +import { SSLCheckerResponse } from "./types"; + +// Calculate days remaining from expiration date +export function calculateDaysRemaining(validTo: string): number { + try { + const expirationDate = new Date(validTo); + const currentDate = new Date(); + const diffTime = expirationDate.getTime() - currentDate.getTime(); + return Math.ceil(diffTime / (1000 * 3600 * 24)); // Convert ms to days + } catch (error) { + console.error("Error calculating days remaining:", error); + return 0; + } +} + +// Check if the certificate is valid +export function isValid(validTo: string): boolean { + try { + return new Date(validTo).getTime() > Date.now(); + } catch (error) { + console.error("Error checking certificate validity:", error); + return false; + } +} + +// Convert results to our expected response format +export function convertResultToResponse(result: any): SSLCheckerResponse { + return { + version: "1.0", + app: "ssl-checker", + host: result.host || "", + response_time_sec: result.response_time_sec || "0.5", + status: result.status || "ok", + result: { + host: result.host || "", + issued_to: result.subject || result.host || "", + issuer_o: result.issuer || "Unknown", + cert_sn: result.serial_number || "0", + cert_alg: result.algorithm || "Unknown", + cert_sans: result.sans || "", + cert_exp: !result.is_valid, + cert_valid: result.is_valid || false, + valid_from: result.valid_from || new Date().toISOString(), + valid_till: result.valid_to || new Date().toISOString(), + validity_days: result.validity_days || 365, + days_left: result.days_remaining || 0, + valid_days_to_expire: result.days_remaining || 0 + } + }; +} \ No newline at end of file