diff --git a/application/src/components/services/ResponseTimeChart.tsx b/application/src/components/services/ResponseTimeChart.tsx index affb107..1383863 100644 --- a/application/src/components/services/ResponseTimeChart.tsx +++ b/application/src/components/services/ResponseTimeChart.tsx @@ -37,10 +37,31 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) { date: format(timestamp, 'MMM dd, yyyy'), value: data.status === "paused" ? null : data.responseTime, status: data.status, + // Separate values for different statuses with proper positioning + upValue: data.status === "up" ? data.responseTime : null, + downValue: data.status === "down" ? data.responseTime : null, + warningValue: data.status === "warning" ? data.responseTime : null, }; }); }, [uptimeData]); + // Calculate Y-axis domain for better positioning + const yAxisDomain = useMemo(() => { + if (!chartData.length) return ['dataMin - 10', 'dataMax + 10']; + + const allValues = chartData + .filter(d => d.value !== null && d.status !== 'paused') + .map(d => d.value); + + if (allValues.length === 0) return [0, 100]; + + const minValue = Math.min(...allValues); + const maxValue = Math.max(...allValues); + const padding = (maxValue - minValue) * 0.1 || 10; + + return [Math.max(0, minValue - padding), maxValue + padding]; + }, [chartData]); + // Create a custom tooltip for the chart const CustomTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { @@ -79,31 +100,6 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) { return null; }; - // Compute status segments for different areas - const getStatusSegments = () => { - const segments = { - up: [] as any[], - down: [] as any[], - warning: [] as any[] - }; - - chartData.forEach(point => { - if (point.status === "paused") return; - - if (point.status === "up") { - segments.up.push(point); - } else if (point.status === "down") { - segments.down.push(point); - } else if (point.status === "warning") { - segments.warning.push(point); - } - }); - - return segments; - }; - - const segments = getStatusSegments(); - // Check if we have any data to display - be more lenient by checking raw uptimeData const hasData = uptimeData.length > 0; @@ -172,46 +168,40 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) { } /> - {/* Area charts for different statuses */} - {segments.up.length > 0 && ( - - )} + {/* Separate area charts for each status - positioned closer together */} + - {segments.down.length > 0 && ( - - )} + - {segments.warning.length > 0 && ( - - )} + {/* Add reference lines for paused periods */} {chartData.map((entry, index) => @@ -231,4 +221,4 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) { ); -} +} \ No newline at end of file diff --git a/application/src/components/services/ServiceDetailContainer/hooks/useRealTimeUpdates.tsx b/application/src/components/services/ServiceDetailContainer/hooks/useRealTimeUpdates.tsx index 20f0687..1d096df 100644 --- a/application/src/components/services/ServiceDetailContainer/hooks/useRealTimeUpdates.tsx +++ b/application/src/components/services/ServiceDetailContainer/hooks/useRealTimeUpdates.tsx @@ -55,7 +55,8 @@ export const useRealTimeUpdates = ({ setUptimeData(prev => { const newData: UptimeData = { id: e.record.id, - serviceId: e.record.service_id, + service_id: e.record.service_id, // Include service_id + serviceId: e.record.service_id, // Keep for backward compatibility timestamp: e.record.timestamp, status: e.record.status, responseTime: e.record.response_time || 0, @@ -84,4 +85,4 @@ export const useRealTimeUpdates = ({ console.error("Error setting up real-time updates:", error); } }, [serviceId, startDate, endDate, setService, setUptimeData]); -}; +}; \ No newline at end of file diff --git a/application/src/components/services/hooks/useUptimeData.ts b/application/src/components/services/hooks/useUptimeData.ts index 91e5391..34f7b88 100644 --- a/application/src/components/services/hooks/useUptimeData.ts +++ b/application/src/components/services/hooks/useUptimeData.ts @@ -68,7 +68,8 @@ export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseU const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({ id: `placeholder-${serviceId}-${index}`, - serviceId: serviceId || "", + service_id: serviceId || "", // Include service_id + serviceId: serviceId || "", // Keep for backward compatibility timestamp: new Date(Date.now() - (index * interval * 1000)).toISOString(), status: statusValue as "up" | "down" | "warning" | "paused", responseTime: 0 @@ -96,7 +97,8 @@ export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseU return { id: `padding-${serviceId}-${index}`, - serviceId: serviceId || "", + service_id: serviceId || "", // Include service_id + serviceId: serviceId || "", // Keep for backward compatibility timestamp: new Date(baseTime - timeOffset).toISOString(), status: lastStatus, responseTime: 0 diff --git a/application/src/components/services/incident-history/IncidentTable.tsx b/application/src/components/services/incident-history/IncidentTable.tsx index 5f7c125..d8118ed 100644 --- a/application/src/components/services/incident-history/IncidentTable.tsx +++ b/application/src/components/services/incident-history/IncidentTable.tsx @@ -25,6 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) { {t("time")} {t("status")} {t("responseTime")} + Error Message + Details @@ -52,10 +54,28 @@ export function IncidentTable({ incidents }: IncidentTableProps) { ? `${check.responseTime}ms` : "N/A"} + + {check.error_message ? ( +
+ {check.error_message} +
+ ) : ( + - + )} +
+ + {check.details ? ( +
+ {check.details} +
+ ) : ( + - + )} +
); })}
); -} +} \ No newline at end of file diff --git a/application/src/components/ssl-domain/AddSSLCertificateForm.tsx b/application/src/components/ssl-domain/AddSSLCertificateForm.tsx index 8314d36..3cb0a5e 100644 --- a/application/src/components/ssl-domain/AddSSLCertificateForm.tsx +++ b/application/src/components/ssl-domain/AddSSLCertificateForm.tsx @@ -1,3 +1,4 @@ + import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -18,7 +19,8 @@ const formSchema = z.object({ domain: z.string().min(1, "Domain is required"), warning_threshold: z.coerce.number().int().min(1).max(365), expiry_threshold: z.coerce.number().int().min(1).max(30), - notification_channel: z.string().min(1, "Notification channel is required") + notification_channel: z.string().min(1, "Notification channel is required"), + check_interval: z.coerce.number().int().min(1).max(30).optional() }); interface AddSSLCertificateFormProps { @@ -42,7 +44,8 @@ export const AddSSLCertificateForm = ({ domain: "", warning_threshold: 30, expiry_threshold: 7, - notification_channel: "" + notification_channel: "", + check_interval: 1 } }); @@ -89,7 +92,8 @@ export const AddSSLCertificateForm = ({ domain: values.domain, warning_threshold: values.warning_threshold, expiry_threshold: values.expiry_threshold, - notification_channel: values.notification_channel + notification_channel: values.notification_channel, + check_interval: values.check_interval }; await onSubmit(certData); @@ -117,34 +121,53 @@ export const AddSSLCertificateForm = ({ )} /> +
+ ( + + {t('warningThreshold')} + + + + + {t('getNotifiedExpiration')} + + + + )} + /> + + ( + + {t('expiryThreshold')} + + + + + {t('getNotifiedCritical')} + + + + )} + /> +
+ ( - - {t('warningThreshold')} - - - - - {t('getNotifiedExpiration')} - - - - )} - /> - - ( - {t('expiryThreshold')} + Check Interval (Days) - + - {t('getNotifiedCritical')} + How often to check the SSL certificate (in days) diff --git a/application/src/components/ssl-domain/EditSSLCertificateForm.tsx b/application/src/components/ssl-domain/EditSSLCertificateForm.tsx index 1512ce4..0e1cad1 100644 --- a/application/src/components/ssl-domain/EditSSLCertificateForm.tsx +++ b/application/src/components/ssl-domain/EditSSLCertificateForm.tsx @@ -1,3 +1,4 @@ + import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -17,6 +18,7 @@ const formSchema = z.object({ warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"), expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"), notification_channel: z.string().min(1, "Notification channel is required"), + check_interval: z.coerce.number().int().min(1).max(30).optional(), }); type FormValues = z.infer; @@ -40,6 +42,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend warning_threshold: certificate.warning_threshold, expiry_threshold: certificate.expiry_threshold, notification_channel: certificate.notification_channel, + check_interval: certificate.check_interval || 1, }, }); @@ -78,7 +81,8 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend ...data, // Ensure values are correctly typed as numbers warning_threshold: Number(data.warning_threshold), - expiry_threshold: Number(data.expiry_threshold) + expiry_threshold: Number(data.expiry_threshold), + check_interval: data.check_interval ? Number(data.check_interval) : undefined }; console.log("Submitting updated certificate:", updatedCertificate); @@ -113,7 +117,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend )} /> -
+
)} /> + + ( + + Check Interval (Days) + + + + + How often to check + + + + )} + />
); -}; +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLCertificateActions.tsx b/application/src/components/ssl-domain/SSLCertificateActions.tsx new file mode 100644 index 0000000..a1a2148 --- /dev/null +++ b/application/src/components/ssl-domain/SSLCertificateActions.tsx @@ -0,0 +1,70 @@ + +import React from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { MoreHorizontal, RefreshCw, Edit, Trash2, Eye } from "lucide-react"; +import { SSLCertificate } from "@/types/ssl.types"; +import { triggerImmediateCheck } from "@/services/sslCertificateService"; +import { toast } from "sonner"; +import { useLanguage } from "@/contexts/LanguageContext"; + +interface SSLCertificateActionsProps { + certificate: SSLCertificate; + onView: (certificate: SSLCertificate) => void; + onEdit: (certificate: SSLCertificate) => void; + onDelete: (certificate: SSLCertificate) => void; +} + +export const SSLCertificateActions = ({ + certificate, + onView, + onEdit, + onDelete +}: SSLCertificateActionsProps) => { + const { t } = useLanguage(); + + const handleCheck = async () => { + try { + await triggerImmediateCheck(certificate.id); + } catch (error) { + console.error("Error triggering SSL check:", error); + } + }; + + return ( + + + + + + onView(certificate)}> + + {t('view')} + + + + Check + + onEdit(certificate)}> + + {t('edit')} + + onDelete(certificate)} + className="text-destructive" + > + + {t('delete')} + + + + ); +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLCertificateDetailDialog.tsx b/application/src/components/ssl-domain/SSLCertificateDetailDialog.tsx new file mode 100644 index 0000000..df23233 --- /dev/null +++ b/application/src/components/ssl-domain/SSLCertificateDetailDialog.tsx @@ -0,0 +1,219 @@ + +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { SSLCertificate } from "@/types/ssl.types"; +import { SSLStatusBadge } from "./SSLStatusBadge"; +import { useLanguage } from "@/contexts/LanguageContext"; + +interface SSLCertificateDetailDialogProps { + certificate: SSLCertificate | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const SSLCertificateDetailDialog = ({ + certificate, + open, + onOpenChange, +}: SSLCertificateDetailDialogProps) => { + const { t } = useLanguage(); + + if (!certificate) return null; + + return ( + + + + {t('sslCertificateDetails')} + + {t('viewDetailedInformation')} {certificate.domain} + + + +
+ {/* Domain & Status Overview */} + + + + {certificate.domain} + + + + +
+
+
+

{t('status')}

+ +
+
+

{t('daysLeft')}

+ + {certificate.days_left} {t('days')} + +
+
+
+
+

{t('validFrom')}

+

+ {certificate.valid_from ? new Date(certificate.valid_from).toLocaleString() : 'N/A'} +

+
+
+

{t('validUntil')}

+

+ {certificate.valid_till ? new Date(certificate.valid_till).toLocaleString() : 'N/A'} +

+
+
+
+
+

{t('validityPeriod')}

+

{certificate.validity_days || 0} {t('days')}

+
+ {certificate.resolved_ip && ( +
+

{t('resolvedIP')}

+

{certificate.resolved_ip}

+
+ )} +
+
+
+
+ + {/* Certificate Information */} + + + {t('certificateDetails')} + + +
+
+
+

{t('issuedTo')}

+

{certificate.issued_to || 'N/A'}

+
+
+

{t('issuer')}

+

{certificate.issuer_o || certificate.issuer_cn || 'N/A'}

+
+ {certificate.issuer_cn && certificate.issuer_cn !== certificate.issuer_o && ( +
+

Issuer Common Name

+

{certificate.issuer_cn}

+
+ )} +
+
+ {certificate.serial_number && ( +
+

{t('serialNumber')}

+

{certificate.serial_number}

+
+ )} + {certificate.cert_alg && ( +
+

{t('algorithm')}

+

{certificate.cert_alg}

+
+ )} +
+
+ + {certificate.cert_sans && ( +
+

{t('subjectAlternativeNames')}

+
+

{certificate.cert_sans}

+
+
+ )} +
+
+ + {/* Monitoring Configuration */} + + + {t('monitoringConfiguration')} + + +
+
+

{t('warningThreshold')}

+

{certificate.warning_threshold} {t('days')}

+
+
+

{t('expiryThreshold')}

+

{certificate.expiry_threshold} {t('days')}

+
+
+

Check Interval

+

{certificate.check_interval || 1} {t('days')}

+
+
+

{t('notificationChannel')}

+

{certificate.notification_channel || 'N/A'}

+
+
+ + {certificate.last_notified && ( +
+

{t('lastNotified')}

+

{new Date(certificate.last_notified).toLocaleString()}

+
+ )} +
+
+ + {/* System Information */} + + + {t('technicalInformation')} + + +
+
+ {certificate.check_at && ( +
+

Next Check

+

{new Date(certificate.check_at).toLocaleString()}

+
+ )} + {certificate.created && ( +
+

{t('created')}

+

{new Date(certificate.created).toLocaleString()}

+
+ )} +
+
+ {certificate.updated && ( +
+

{t('lastUpdated')}

+

{new Date(certificate.updated).toLocaleString()}

+
+ )} +
+

Certificate ID

+

{certificate.id}

+
+
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLCertificatesTable.tsx b/application/src/components/ssl-domain/SSLCertificatesTable.tsx index 43ec130..ce6fb15 100644 --- a/application/src/components/ssl-domain/SSLCertificatesTable.tsx +++ b/application/src/components/ssl-domain/SSLCertificatesTable.tsx @@ -1,362 +1,255 @@ import React, { useState } from "react"; -import { format } from "date-fns"; -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell -} from "@/components/ui/table"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; -import { SSLCertificate } from "@/types/ssl.types"; -import { SSLStatusBadge } from "./SSLStatusBadge"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toast } from "sonner"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { SSLStatusBadge } from "./SSLStatusBadge"; +import { AddSSLCertificateForm } from "./AddSSLCertificateForm"; +import { EditSSLCertificateForm } from "./EditSSLCertificateForm"; +import { SSLCertificateActions } from "./SSLCertificateActions"; +import { SSLCertificateDetailDialog } from "./SSLCertificateDetailDialog"; +import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService"; +import { pb } from "@/lib/pocketbase"; +import { SSLCertificate } from "@/types/ssl.types"; import { useLanguage } from "@/contexts/LanguageContext"; +import { toast } from "sonner"; -interface SSLCertificatesTableProps { - certificates: SSLCertificate[]; - onRefresh: (id: string) => void; - refreshingId: string | null; - onEdit?: (certificate: SSLCertificate) => void; - onDelete?: (certificate: SSLCertificate) => void; -} - -export const SSLCertificatesTable = ({ - certificates, - onRefresh, - refreshingId, - onEdit, - onDelete -}: SSLCertificatesTableProps) => { +export const SSLCertificatesTable = () => { const { t } = useLanguage(); - const [selectedCert, setSelectedCert] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [certToDelete, setCertToDelete] = useState(null); - - const formatDate = (dateString: string | undefined) => { - if (!dateString) return t('unknown'); - + const queryClient = useQueryClient(); + const [showAddDialog, setShowAddDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showViewDialog, setShowViewDialog] = useState(false); + const [selectedCertificate, setSelectedCertificate] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: certificates = [], isLoading, isError } = useQuery({ + queryKey: ['ssl-certificates'], + queryFn: fetchSSLCertificates, + }); + + if (isLoading) return
Loading...
; + if (isError) return
Error loading certificates
; + + const handleAddCertificate = async (data: any) => { + setIsSubmitting(true); try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - console.warn("Invalid date for formatting:", dateString); - return t('unknown'); - } - return format(date, "MMM dd, yyyy"); + await addSSLCertificate(data); + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowAddDialog(false); + toast.success(t('certificateAdded')); } catch (error) { - console.error("Error formatting date:", error); - return t('unknown'); + console.error("Error adding certificate:", error); + toast.error(t('failedToAddCertificate')); + } finally { + setIsSubmitting(false); } }; - const handleViewCertificate = (certificate: SSLCertificate) => { - setSelectedCert(certificate); + const handleEditCertificate = async (updatedCertificate: SSLCertificate) => { + setIsSubmitting(true); + try { + await pb.collection('ssl_certificates').update(updatedCertificate.id, { + warning_threshold: updatedCertificate.warning_threshold, + expiry_threshold: updatedCertificate.expiry_threshold, + notification_channel: updatedCertificate.notification_channel, + check_interval: updatedCertificate.check_interval, + }); + + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowEditDialog(false); + setSelectedCertificate(null); + toast.success(t('certificateUpdated')); + } catch (error) { + console.error("Error updating certificate:", error); + toast.error(t('failedToUpdateCertificate')); + } finally { + setIsSubmitting(false); + } }; - const handleEditCertificate = (certificate: SSLCertificate) => { - if (onEdit) { - onEdit(certificate); - } else { - toast.error("Edit functionality not implemented yet"); + const handleDeleteCertificate = async () => { + if (!selectedCertificate) return; + + setIsSubmitting(true); + try { + await deleteSSLCertificate(selectedCertificate.id); + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowDeleteDialog(false); + setSelectedCertificate(null); + toast.success(t('certificateDeleted')); + } catch (error) { + console.error("Error deleting certificate:", error); + toast.error(t('failedToDeleteCertificate')); + } finally { + setIsSubmitting(false); } }; - const handleDeleteCertificate = (certificate: SSLCertificate) => { - setCertToDelete(certificate); - setDeleteConfirmOpen(true); + const openViewDialog = (certificate: SSLCertificate) => { + setSelectedCertificate(certificate); + setShowViewDialog(true); }; - const confirmDelete = () => { - if (certToDelete && onDelete) { - onDelete(certToDelete); - setDeleteConfirmOpen(false); - setCertToDelete(null); - } + const openEditDialog = (certificate: SSLCertificate) => { + setSelectedCertificate(certificate); + setShowEditDialog(true); + }; + + const openDeleteDialog = (certificate: SSLCertificate) => { + setSelectedCertificate(certificate); + setShowDeleteDialog(true); }; return ( <> -
- {refreshingId && ( -
-
- - {t('checkingSSLCertificate')} -
-
- )} - - - - {t('domain')} - {t('issuer')} - {t('expirationDate')} - {t('daysLeft')} - {t('status')} - {t('lastNotified')} - {t('actions')} - - - - {certificates.length === 0 ? ( + + +
+ - - {t('noSSLCertificates')} - + {t('domain')} + {t('status')} + {t('issuer')} + {t('validUntil')} + {t('daysLeft')} + Check Interval + - ) : ( - certificates.map((certificate) => ( + + + {certificates.map((certificate) => ( - {certificate.domain} - {certificate.issuer_o || t('unknown')} + + {certificate.domain} + - {formatDate(certificate.valid_till)} + + {certificate.issuer_o || certificate.issuer_cn || 'Unknown'} - {typeof certificate.days_left === 'number' ? certificate.days_left : t('unknown')} + {certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'} - + + {certificate.days_left} {t('days')} + - {certificate.last_notified - ? formatDate(certificate.last_notified) - : t('never')} + {certificate.check_interval || 1} {t('days')} - - - - - - - handleViewCertificate(certificate)} - className="cursor-pointer" - > - {t('view')} - - { - if (refreshingId === null) { - onRefresh(certificate.id); - } - }} - disabled={refreshingId !== null} - className="cursor-pointer" - > - - {t('check')} - - handleEditCertificate(certificate)} - className="cursor-pointer" - > - {t('edit')} - - handleDeleteCertificate(certificate)} - className="cursor-pointer text-destructive focus:text-destructive" - > - {t('delete')} - - - + + - )) - )} - -
-
+ ))} + + - {/* SSL Certificate Details Dialog */} - !open && setSelectedCert(null)}> - - - - {t('sslCertificateDetails')} - -

- {t('detailedInfo')} {selectedCert?.domain} -

-
- - {selectedCert && ( -
-
- {/* Basic Information */} -
-

{t('basicInformation')}

-
-
- {t('domain')}: - {selectedCert.domain} -
-
- {t('status')}: - -
-
- {t('issuer')}: - {selectedCert.issued_to || t('unknown')} -
-
- IP: - {selectedCert.resolved_ip || t('unknown')} -
-
-
- - {/* Validity */} -
-

{t('validity')}

-
-
- {t('validFrom')}: - {selectedCert.valid_from ? formatDate(selectedCert.valid_from) : t('unknown')} -
-
- {t('validUntil')}: - {formatDate(selectedCert.valid_till)} -
-
- {t('daysLeft')}: - {selectedCert.days_left} -
-
- {t('validityDays')}: - {selectedCert.validity_days || t('unknown')} -
-
-
- - {/* Issuer */} -
-

{t('issuerInfo')}

-
-
- {t('organization')}: - {selectedCert.issuer_o || t('unknown')} -
-
- {t('commonName')}: - {selectedCert.issuer_cn || t('unknown')} -
-
-
- - {/* Technical Details */} -
-

{t('technicalDetails')}

-
-
- {t('serialNumber')}: - {selectedCert.serial_number || t('unknown')} -
-
- {t('algorithm')}: - {selectedCert.cert_alg || t('unknown')} -
-
-
-
- - {/* Subject Alternative Names */} -
-

{t('subjectAltNames')}

-
- {selectedCert.cert_sans ? ( -

{selectedCert.cert_sans}

- ) : ( -

{t('none')}

- )} -
-
- - {/* Monitoring Configuration */} -
-

{t('monitoringConfig')}

-
-
-
{t('warningThreshold')}:
-
{selectedCert.warning_threshold} {t('daysLeft').toLowerCase()}
-
-
-
{t('expiryThreshold')}:
-
{selectedCert.expiry_threshold} {t('daysLeft').toLowerCase()}
-
-
-
{t('notificationChannel')}:
-
{selectedCert.notification_channel}
-
-
-
- - {/* Timestamps */} -
-

{t('recordInfo')}

-
-
-
{t('created')}:
-
{selectedCert.created ? formatDate(selectedCert.created) : t('unknown')}
-
-
-
{t('lastUpdated')}:
-
{selectedCert.updated ? formatDate(selectedCert.updated) : t('unknown')}
-
-
-
{t('lastNotification')}:
-
{selectedCert.last_notified ? formatDate(selectedCert.last_notified) : t('never')}
-
-
-
{t('collectionId')}:
-
{selectedCert.collectionId || t('unknown')}
-
-
-
+ {certificates.length === 0 && ( +
+ {t('noCertificatesFound')}
)} - - - - + + + + {/* View Certificate Dialog */} + + + {/* Add Certificate Dialog */} + + + + {t('addSSLCertificate')} + + {t('addCertificateDescription')} + + + setShowAddDialog(false)} + isPending={isSubmitting} + /> - {/* Delete Confirmation Dialog */} - - + {/* Edit Certificate Dialog */} + + - {t('deleteSSLCertificate')} + {t('editSSLCertificate')} + + {t('editCertificateDescription')} + -
-

{t('deleteConfirmation')} {certToDelete?.domain}?

-

{t('deleteWarning')}

-
- - - - + {selectedCertificate && ( + setShowEditDialog(false)} + isPending={isSubmitting} + /> + )}
+ + {/* Delete Certificate Dialog */} + + + + {t('deleteCertificate')} + + {t('deleteCertificateConfirmation')} {selectedCertificate?.domain}? + + + + {t('cancel')} + + {isSubmitting ? t('deleting') : t('delete')} + + + + ); }; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLDomainContent.tsx b/application/src/components/ssl-domain/SSLDomainContent.tsx index f4061fe..bc7af16 100644 --- a/application/src/components/ssl-domain/SSLDomainContent.tsx +++ b/application/src/components/ssl-domain/SSLDomainContent.tsx @@ -1,3 +1,4 @@ + import React, { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -242,13 +243,7 @@ export const SSLDomainContent = () => {
- +
diff --git a/application/src/services/monitoring/handlers/serviceStatusHandlers.ts b/application/src/services/monitoring/handlers/serviceStatusHandlers.ts index 8bd31c0..9405c38 100644 --- a/application/src/services/monitoring/handlers/serviceStatusHandlers.ts +++ b/application/src/services/monitoring/handlers/serviceStatusHandlers.ts @@ -13,7 +13,8 @@ export async function handleServiceUp(service: any, responseTime: number, format // Create a history record of this check with a more accurate timestamp const uptimeData: UptimeData = { - serviceId: service.id, + service_id: service.id, // Include service_id + serviceId: service.id, // Keep for backward compatibility timestamp: new Date().toISOString(), status: "up", responseTime: responseTime, @@ -92,7 +93,8 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr // Create a history record of this check const uptimeData: UptimeData = { - serviceId: service.id, + service_id: service.id, // Include service_id + serviceId: service.id, // Keep for backward compatibility timestamp: new Date().toISOString(), status: "down", responseTime: 0, @@ -161,4 +163,4 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr } catch (error) { console.error("Error handling service DOWN state:", error); } -} +} \ No newline at end of file diff --git a/application/src/services/monitoring/service-status/resumeMonitoring.ts b/application/src/services/monitoring/service-status/resumeMonitoring.ts index 0c4f020..47fdcc6 100644 --- a/application/src/services/monitoring/service-status/resumeMonitoring.ts +++ b/application/src/services/monitoring/service-status/resumeMonitoring.ts @@ -37,6 +37,7 @@ export async function resumeMonitoring(serviceId: string): Promise { id: service.id, name: service.name, url: service.url || "", + host: service.host || "", // Include host property type: service.service_type || service.type || "HTTP", status: "up", responseTime: service.response_time || 0, @@ -77,4 +78,4 @@ export async function resumeMonitoring(serviceId: string): Promise { } catch (error) { console.error("Error resuming service:", error); } -} +} \ No newline at end of file diff --git a/application/src/services/monitoring/utils/httpUtils.ts b/application/src/services/monitoring/utils/httpUtils.ts index 08f959d..1987b19 100644 --- a/application/src/services/monitoring/utils/httpUtils.ts +++ b/application/src/services/monitoring/utils/httpUtils.ts @@ -19,6 +19,7 @@ export function prepareServiceForNotification(pbRecord: any, status: string, res id: pbRecord.id, name: pbRecord.name, url: pbRecord.url, + host: pbRecord.host || "", // Include host property with fallback type: pbRecord.type || pbRecord.service_type || "HTTP", status: status as any, responseTime: responseTime, diff --git a/application/src/services/ssl/index.ts b/application/src/services/ssl/index.ts index 129df11..c12a334 100644 --- a/application/src/services/ssl/index.ts +++ b/application/src/services/ssl/index.ts @@ -2,9 +2,6 @@ // 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'; @@ -16,7 +13,8 @@ export { addSSLCertificate, checkAndUpdateCertificate, deleteSSLCertificate, - refreshAllCertificates + refreshAllCertificates, + triggerImmediateCheck } from './sslCertificateOperations'; // SSL-specific notification service @@ -28,10 +26,7 @@ export { } from './notification'; // Export types -export type { SSLCheckerResponse, SSLCertificate, AddSSLCertificateDto, SSLNotification } from './types'; - -// Export utility functions -export { normalizeDomain, createErrorResponse } from './sslCheckerUtils'; +export type { SSLCertificate, AddSSLCertificateDto, SSLNotification } from './types'; -// Export checking mechanisms -export { checkWithFetch } from './sslPrimaryChecker'; \ No newline at end of file +// Export utility functions for SSL operations +export { calculateDaysRemaining, isValid } from './utils'; \ No newline at end of file diff --git a/application/src/services/ssl/notification/sslCheckNotifier.ts b/application/src/services/ssl/notification/sslCheckNotifier.ts index 1b5cab9..356dadf 100644 --- a/application/src/services/ssl/notification/sslCheckNotifier.ts +++ b/application/src/services/ssl/notification/sslCheckNotifier.ts @@ -1,7 +1,6 @@ import { pb } from "@/lib/pocketbase"; import { SSLCertificate } from "../types"; -import { checkSSLCertificate } from "../sslCheckerService"; import { determineSSLStatus } from "../sslStatusUtils"; import { sendSSLNotification } from "./sslNotificationSender"; import { toast } from "sonner"; @@ -35,20 +34,14 @@ export async function checkAllCertificatesAndNotify(): Promise { /** * Checks a specific SSL certificate and sends notification if needed * This respects the Warning and Expiry Thresholds set on the certificate + * Note: SSL checking is now handled by the Go service, this function focuses on notifications */ export async function checkCertificateAndNotify(certificate: SSLCertificate): Promise { console.log(`Checking certificate for ${certificate.domain}...`); try { - // Get fresh SSL data - const sslData = await checkSSLCertificate(certificate.domain); - if (!sslData || !sslData.result) { - console.error(`Failed to check SSL for ${certificate.domain}`); - return false; - } - - // Extract days left from the check result - const daysLeft = sslData.result.days_left || 0; + // Use the current certificate data (updated by Go service) + const daysLeft = certificate.days_left || 0; // Get threshold values (ensure they are numbers) const warningThreshold = Number(certificate.warning_threshold) || 30; @@ -76,21 +69,10 @@ export async function checkCertificateAndNotify(certificate: SSLCertificate): Pr console.log(`${certificate.domain}: ${daysLeft} days left, status: ${status}, should notify: ${shouldNotify}, critical: ${isCritical}`); - // Update certificate data in database - const updateData: Partial = { - days_left: daysLeft, - status: status, - // Other fields from the SSL check that should be updated - issuer_o: sslData.result.issuer_o || certificate.issuer_o, - valid_from: sslData.result.valid_from || certificate.valid_from, - valid_till: sslData.result.valid_till || certificate.valid_till, - validity_days: sslData.result.validity_days || certificate.validity_days, - cert_sans: sslData.result.cert_sans, - cert_alg: sslData.result.cert_alg, - serial_number: sslData.result.cert_sn - }; - - await pb.collection('ssl_certificates').update(certificate.id, updateData); + // Update certificate status in database + await pb.collection('ssl_certificates').update(certificate.id, { + status: status + }); // Send notification if needed if (shouldNotify && certificate.notification_channel) { @@ -135,34 +117,4 @@ export async function checkCertificateAndNotify(certificate: SSLCertificate): Pr toast.error(`Error checking certificate: ${error instanceof Error ? error.message : "Unknown error"}`); return false; } -} - -/** - * Manual trigger for certificate notification test - * Useful for testing notification channels - */ -export async function testCertificateNotification(certificate: SSLCertificate): Promise { - try { - console.log(`Testing notification for ${certificate.domain}...`); - - // Create test message - const message = `๐Ÿงช TEST: SSL Certificate notification for ${certificate.domain}.`; - - // We set isCritical to false for test notifications - const notificationSent = await sendSSLNotification(certificate, message, false); - - if (notificationSent) { - console.log(`Test notification sent for ${certificate.domain}`); - toast.success(`Test notification sent for ${certificate.domain}`); - return true; - } else { - console.error(`Failed to send test notification for ${certificate.domain}`); - toast.error(`Failed to send test notification for ${certificate.domain}`); - return false; - } - } catch (error) { - console.error(`Error sending test notification for ${certificate.domain}:`, error); - toast.error(`Error sending test notification: ${error instanceof Error ? error.message : "Unknown error"}`); - return false; - } } \ No newline at end of file diff --git a/application/src/services/ssl/sslCertificateOperations.ts b/application/src/services/ssl/sslCertificateOperations.ts index 9dd9c16..dc37b67 100644 --- a/application/src/services/ssl/sslCertificateOperations.ts +++ b/application/src/services/ssl/sslCertificateOperations.ts @@ -1,45 +1,36 @@ 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 + * Note: SSL checking is now handled by the Go service */ 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 + // The Go service will handle the actual SSL checking 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, + issued_to: certificateData.domain, // Will be updated by Go service + issuer_o: "", // Will be updated by Go service + status: "pending", // Initial status + cert_sans: "", + cert_alg: "", + serial_number: "", + valid_from: new Date().toISOString(), // Will be updated by Go service + valid_till: new Date().toISOString(), // Will be updated by Go service + validity_days: 0, // Will be updated by Go service + days_left: 0, // Will be updated by Go service warning_threshold: Number(certificateData.warning_threshold) || 30, expiry_threshold: Number(certificateData.expiry_threshold) || 7, notification_channel: certificateData.notification_channel || "", + check_interval: Number(certificateData.check_interval) || 1, // New field }; // Save to database @@ -54,6 +45,7 @@ export const addSSLCertificate = async ( /** * Check and update a specific SSL certificate + * Note: This now relies on the Go service for SSL data fetching */ export const checkAndUpdateCertificate = async ( certificateId: string @@ -67,51 +59,40 @@ export const checkAndUpdateCertificate = async ( } 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); + // The Go service will handle the actual SSL checking and updating + // For now, we'll just trigger notifications based on current data + await checkCertificateAndNotify(typedCertificate); - return updatedCertificate; + // Return the current certificate data + return typedCertificate; } catch (error) { console.error("Error updating SSL certificate:", error); throw error; } }; +/** + * Trigger immediate SSL check by setting check_at to current time + */ +export const triggerImmediateCheck = async (certificateId: string): Promise => { + try { + const currentTime = new Date().toISOString(); + + // Update the check_at field to current time to trigger immediate check by Go service + await pb.collection("ssl_certificates").update(certificateId, { + check_at: currentTime + }); + + console.log(`Triggered immediate check for certificate ${certificateId} at ${currentTime}`); + toast.success("SSL check scheduled - certificate will be checked shortly"); + } catch (error) { + console.error("Error triggering immediate SSL check:", error); + toast.error("Failed to schedule SSL check"); + throw error; + } +}; + /** * Delete an SSL certificate from monitoring */ @@ -127,6 +108,7 @@ export const deleteSSLCertificate = async (id: string): Promise => { /** * Refresh all SSL certificates + * Note: The Go service handles the actual SSL checking */ export const refreshAllCertificates = async (): Promise<{ success: number; failed: number }> => { try { @@ -140,7 +122,7 @@ export const refreshAllCertificates = async (): Promise<{ success: number; faile for (const cert of certificates) { try { - await checkAndUpdateCertificate(cert.id); + await checkCertificateAndNotify(cert); success++; } catch (error) { console.error(`Failed to refresh certificate ${cert.domain}:`, error); diff --git a/application/src/services/ssl/types.ts b/application/src/services/ssl/types.ts index 5797aa7..6d73b74 100644 --- a/application/src/services/ssl/types.ts +++ b/application/src/services/ssl/types.ts @@ -1,74 +1,48 @@ +// SSL Certificate DTO for adding new certificates +export interface AddSSLCertificateDto { + domain: string; + warning_threshold: number; + expiry_threshold: number; + notification_channel: string; + check_interval?: number; // New field for check interval in days +} -// 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 +// 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; + // New fields + check_interval?: number; // Check interval in days + check_at?: string; // Next check time + // Existing fields based on the provided structure + collectionId?: string; + collectionName?: string; + resolved_ip?: string; + issuer_cn?: 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 index dc243b3..9140590 100644 --- a/application/src/services/ssl/utils.ts +++ b/application/src/services/ssl/utils.ts @@ -1,6 +1,4 @@ -import { SSLCheckerResponse } from "./types"; - // Calculate days remaining from expiration date export function calculateDaysRemaining(validTo: string): number { try { @@ -22,30 +20,4 @@ export function isValid(validTo: string): boolean { 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 diff --git a/application/src/services/sslCertificateService.ts b/application/src/services/sslCertificateService.ts index 8682135..2cdb65f 100644 --- a/application/src/services/sslCertificateService.ts +++ b/application/src/services/sslCertificateService.ts @@ -1,10 +1,11 @@ // This file re-exports all SSL certificate related services for backward compatibility import { - checkSSLCertificate, fetchSSLCertificates, addSSLCertificate, - checkAndUpdateCertificate + checkAndUpdateCertificate, + triggerImmediateCheck, + deleteSSLCertificate } from './ssl'; import { determineSSLStatus } from './ssl/sslStatusUtils'; @@ -17,11 +18,12 @@ import { } from './ssl/notification'; export { - checkSSLCertificate, determineSSLStatus, fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate, + triggerImmediateCheck, + deleteSSLCertificate, checkAllCertificatesAndNotify, checkCertificateAndNotify, shouldRunDailyCheck diff --git a/application/src/services/uptimeService.ts b/application/src/services/uptimeService.ts index 5a6808c..10fa139 100644 --- a/application/src/services/uptimeService.ts +++ b/application/src/services/uptimeService.ts @@ -31,22 +31,23 @@ const getCollectionForServiceType = (serviceType: string): string => { export const uptimeService = { async recordUptimeData(data: UptimeData): Promise { try { - console.log(`Recording uptime data for service ${data.serviceId}: Status ${data.status}, Response time: ${data.responseTime}ms`); + console.log(`Recording uptime data for service ${data.serviceId || data.service_id}: Status ${data.status}, Response time: ${data.responseTime}ms`); const options = { $autoCancel: false, - $cancelKey: `uptime_record_${data.serviceId}_${Date.now()}` + $cancelKey: `uptime_record_${data.serviceId || data.service_id}_${Date.now()}` }; const record = await pb.collection('uptime_data').create({ - service_id: data.serviceId, + service_id: data.service_id || data.serviceId, timestamp: data.timestamp, status: data.status, response_time: data.responseTime }, options); // Invalidate cache for this service - const keysToDelete = Array.from(uptimeCache.keys()).filter(key => key.includes(`uptime_${data.serviceId}`)); + const serviceId = data.service_id || data.serviceId; + const keysToDelete = Array.from(uptimeCache.keys()).filter(key => key.includes(`uptime_${serviceId}`)); keysToDelete.forEach(key => uptimeCache.delete(key)); console.log(`Uptime data recorded successfully with ID: ${record.id}`); @@ -116,12 +117,15 @@ export const uptimeService = { // Transform the response items to UptimeData format const uptimeData = response.items.map(item => ({ id: item.id, - serviceId: item.service_id, + service_id: item.service_id, // Include service_id + serviceId: item.service_id, // Keep for backward compatibility timestamp: item.timestamp, status: item.status as "up" | "down" | "warning" | "paused", responseTime: item.response_time || 0, date: item.timestamp, - uptime: 100 + uptime: 100, + error_message: item.error_message, + details: item.details })); // Cache the result diff --git a/application/src/types/service.types.ts b/application/src/types/service.types.ts index a3b810a..b9da956 100644 --- a/application/src/types/service.types.ts +++ b/application/src/types/service.types.ts @@ -2,22 +2,36 @@ export interface Service { id: string; name: string; - url: string; - host?: string; // Add host field for PING and TCP services - port?: number; // Add port field for TCP services - type: "HTTP" | "HTTPS" | "TCP" | "DNS" | "PING" | "HTTP" | "http" | "https" | "tcp" | "dns" | "ping" | "smtp" | "icmp"; - status: "up" | "down" | "paused" | "pending" | "warning"; + url?: string; + host?: string; // Make host optional since it's not always required + port?: number; + domain?: string; // Add domain field for DNS services + type: "http" | "https" | "tcp" | "ping" | "icmp" | "dns"; + status: "up" | "down" | "paused" | "warning"; responseTime: number; - uptime: number; + uptime?: number; lastChecked: string; interval: number; + timeout?: number; retries: number; - notificationChannel?: string; + created?: string; + updated?: string; + notification_channel?: string; + notificationChannel?: string; // Keep for backward compatibility alertTemplate?: string; - muteAlerts?: boolean; // Keep this to avoid breaking existing code alerts?: "muted" | "unmuted"; // Make sure alerts is properly typed as union + muteAlerts?: boolean; // Keep this to avoid breaking existing code muteChangedAt?: string; - domain?: string; // Add domain field for DNS services + follow_redirects?: boolean; + verify_ssl?: boolean; + expected_status_code?: number; + keyword_check?: string; + keyword_check_type?: "contains" | "not_contains"; + dns_record_type?: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"; + dns_expected_value?: string; + headers?: string; + body?: string; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; } export interface CreateServiceParams { @@ -34,11 +48,54 @@ export interface CreateServiceParams { } export interface UptimeData { - date?: string; - uptime?: number; id?: string; - serviceId?: string; + service_id?: string; // Make service_id optional for backward compatibility + serviceId?: string; // Keep for backward compatibility + timestamp: string; + status: "up" | "down" | "paused" | "warning"; + responseTime: number; + error_message?: string; + details?: string; + created?: string; + updated?: string; + date?: string; // Keep for backward compatibility + uptime?: number; // Keep for backward compatibility +} + +export interface PingData { + id?: string; + service_id: string; + timestamp: string; + status: "up" | "down" | "paused" | "warning"; + responseTime: number; + packet_loss?: number; + error_message?: string; + details?: string; + created?: string; + updated?: string; +} + +export interface DNSData { + id?: string; + service_id: string; + timestamp: string; + status: "up" | "down" | "paused" | "warning"; + responseTime: number; + resolved_ip?: string; + error_message?: string; + details?: string; + created?: string; + updated?: string; +} + +export interface TCPData { + id?: string; + service_id: string; timestamp: string; - status: "up" | "down" | "paused" | "pending" | "warning"; + status: "up" | "down" | "paused" | "warning"; responseTime: number; + error_message?: string; + details?: string; + created?: string; + updated?: string; } \ No newline at end of file diff --git a/application/src/types/ssl.types.ts b/application/src/types/ssl.types.ts index 75fc488..68d7427 100644 --- a/application/src/types/ssl.types.ts +++ b/application/src/types/ssl.types.ts @@ -1,4 +1,3 @@ - export interface SSLCertificate { id: string; domain: string; @@ -19,7 +18,10 @@ export interface SSLCertificate { last_notified?: string; created?: string; updated?: string; - // New fields based on the provided structure + // New fields + check_interval?: number; // Check interval in days + check_at?: string; // Next check time + // Existing fields based on the provided structure collectionId?: string; collectionName?: string; resolved_ip?: string; @@ -31,4 +33,5 @@ export interface AddSSLCertificateDto { warning_threshold: number; expiry_threshold: number; notification_channel: string; + check_interval?: number; // New field for check interval in days } \ No newline at end of file diff --git a/server/service-operation/README.md b/server/service-operation/README.md index 46084f2..00089b1 100644 --- a/server/service-operation/README.md +++ b/server/service-operation/README.md @@ -8,10 +8,10 @@ A Go-based microservice for service operations including ICMP ping, DNS resoluti - **ICMP Ping**: Full ping functionality with packet statistics - **DNS Resolution**: A, AAAA, MX, and TXT record lookups - **TCP Connectivity**: Port connectivity testing +- **SSL Certificate**: SSL Certificate Check - REST API endpoints - Health check endpoint - Configurable via environment variables -- Docker support - Comprehensive operation statistics ## API Endpoints diff --git a/server/service-operation/config/config.go b/server/service-operation/config/config.go index 0ff5cc9..024e0a2 100644 --- a/server/service-operation/config/config.go +++ b/server/service-operation/config/config.go @@ -23,7 +23,7 @@ func Load() *Config { cfg := &Config{ Port: getEnv("PORT", "8091"), DefaultCount: getEnvInt("DEFAULT_COUNT", 4), - DefaultTimeout: getEnvDuration("DEFAULT_TIMEOUT", 3*time.Second), + DefaultTimeout: getEnvDuration("DEFAULT_TIMEOUT", 10*time.Second), MaxCount: getEnvInt("MAX_COUNT", 20), MaxTimeout: getEnvDuration("MAX_TIMEOUT", 30*time.Second), EnableLogging: getEnvBool("ENABLE_LOGGING", true), diff --git a/server/service-operation/handlers/operation_handler.go b/server/service-operation/handlers/operation_handler.go index 5cc89b3..1666c85 100644 --- a/server/service-operation/handlers/operation_handler.go +++ b/server/service-operation/handlers/operation_handler.go @@ -79,6 +79,10 @@ func (h *OperationHandler) HandleOperation(w http.ResponseWriter, r *http.Reques } result, err = httpOp.Execute(url, method) + case types.OperationSSL: + sslOp := operations.NewSSLOperation(timeout) + result, err = sslOp.Execute(req.Host) + default: http.Error(w, "Invalid operation type", http.StatusBadRequest) return diff --git a/server/service-operation/main.go b/server/service-operation/main.go index 8ee0670..a78040e 100644 --- a/server/service-operation/main.go +++ b/server/service-operation/main.go @@ -21,6 +21,7 @@ func main() { // Initialize PocketBase client (no credentials required) var pbClient *pocketbase.PocketBaseClient var monitoringService *monitoring.MonitoringService + var sslMonitoringService *monitoring.SSLMonitoringService if cfg.PocketBaseEnabled { var err error @@ -35,6 +36,11 @@ func main() { monitoringService = monitoring.NewMonitoringService(pbClient) go monitoringService.Start() log.Println("Monitoring service started (public access mode)") + + // Initialize and start SSL monitoring service + sslMonitoringService = monitoring.NewSSLMonitoringService(pbClient) + go sslMonitoringService.Start() + log.Println("SSL monitoring service started") } } } @@ -63,13 +69,16 @@ func main() { if monitoringService != nil { log.Printf("Automatic service monitoring enabled") } + if sslMonitoringService != nil { + log.Printf("SSL certificate monitoring enabled") + } log.Printf("Endpoints:") - log.Printf(" POST /operation - Full operation test (ping, dns, tcp, http)") + log.Printf(" POST /operation - Full operation test (ping, dns, tcp, http, ssl)") log.Printf(" GET /operation/quick?type=&host= - Quick operation test") log.Printf(" POST /ping - Legacy ping endpoint") log.Printf(" GET /ping/quick?host= - Legacy quick ping test") log.Printf(" GET /health - Health check") - log.Printf("Supported operations: ping, dns, tcp, http") + log.Printf("Supported operations: ping, dns, tcp, http, ssl") // Setup graceful shutdown c := make(chan os.Signal, 1) @@ -77,10 +86,13 @@ func main() { go func() { <-c - log.Println("Shutting down monitoring service...") + log.Println("Shutting down monitoring services...") if monitoringService != nil { monitoringService.Stop() } + if sslMonitoringService != nil { + sslMonitoringService.Stop() + } log.Println("Service stopped") os.Exit(0) }() diff --git a/server/service-operation/monitoring/ssl_monitor.go b/server/service-operation/monitoring/ssl_monitor.go new file mode 100644 index 0000000..a5055b9 --- /dev/null +++ b/server/service-operation/monitoring/ssl_monitor.go @@ -0,0 +1,314 @@ +package monitoring + +import ( + "fmt" + "log" + "time" + + "service-operation/operations" + "service-operation/pocketbase" + "service-operation/types" +) + +type SSLMonitoringService struct { + pbClient *pocketbase.PocketBaseClient + stopChan chan bool + retryQueue map[string]int // Track retry count per certificate + maxRetries int +} + +func NewSSLMonitoringService(pbClient *pocketbase.PocketBaseClient) *SSLMonitoringService { + return &SSLMonitoringService{ + pbClient: pbClient, + stopChan: make(chan bool), + retryQueue: make(map[string]int), + maxRetries: 3, + } +} + +func (s *SSLMonitoringService) Start() { + ticker := time.NewTicker(1 * time.Minute) // Check every minute for scheduling + defer ticker.Stop() + + log.Println("SSL monitoring service started with interval and check_at scheduling") + + for { + select { + case <-ticker.C: + s.checkSSLCertificates() + case <-s.stopChan: + log.Println("SSL monitoring service stopped") + return + } + } +} + +func (s *SSLMonitoringService) Stop() { + s.stopChan <- true +} + +func (s *SSLMonitoringService) checkSSLCertificates() { + //log.Println("Fetching SSL certificates from PocketBase...") + + certificates, err := s.pbClient.GetSSLCertificates() + if err != nil { + log.Printf("Failed to fetch SSL certificates: %v", err) + return + } + + //log.Printf("Found %d SSL certificates to check", len(certificates)) + + for _, cert := range certificates { + if s.shouldCheckCertificate(cert) { + go s.checkSingleCertificateWithRetry(cert) + } + } +} + +func (s *SSLMonitoringService) shouldCheckCertificate(cert types.SSLCertificate) bool { + now := time.Now() + + // Priority 1: Check if check_at is set and is due + if cert.CheckAt != "" { + if checkAt, err := s.parseFlexibleTime(cert.CheckAt); err == nil { + if now.After(checkAt) || now.Equal(checkAt) { + log.Printf("Certificate %s is due for manual check (check_at: %s)", + cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + return true + } else { + //log.Printf("Certificate %s scheduled for later check (check_at: %s)", + //cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + //return false + } + } else { + log.Printf("Error parsing check_at for %s: %v", cert.Domain, err) + } + } + + // Priority 2: Check based on check_interval (in days) from last update + if cert.Updated == "" { + log.Printf("Certificate %s has never been checked, scheduling check", cert.Domain) + return true + } + + // Parse last check time from updated field + lastCheck, err := s.parseFlexibleTime(cert.Updated) + if err != nil { + log.Printf("Error parsing last check time for %s, scheduling check: %v", cert.Domain, err) + return true + } + + // Get check interval in days (default to 1 day if not set or invalid) + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + // Adjust check interval based on certificate status for critical certificates + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + + // Calculate next check time based on days + nextCheck := lastCheck.Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + shouldCheck := now.After(nextCheck) + + if shouldCheck { + log.Printf("Certificate %s is due for interval check (last: %s, interval: %d days)", + cert.Domain, lastCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + } else { + //log.Printf("Certificate %s not due yet (next check: %s, interval: %d days)", + //cert.Domain, nextCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + } + + return shouldCheck +} + +// parseFlexibleTime tries multiple time formats to parse timestamps +func (s *SSLMonitoringService) parseFlexibleTime(timeStr string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05.999Z", // ISO 8601 with milliseconds (PocketBase format) + "2006-01-02 15:04:05.999999Z", // ISO 8601 with microseconds + "2006-01-02 15:04:05Z", // ISO 8601 without milliseconds + "2006-01-02T15:04:05.999Z", // RFC3339 with milliseconds + "2006-01-02T15:04:05.999999Z", // RFC3339 with microseconds + "2006-01-02T15:04:05Z", // RFC3339 without milliseconds + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05.999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999999Z", + } + + for _, format := range formats { + if t, err := time.Parse(format, timeStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time string: %s", timeStr) +} + +// adjustCheckIntervalDays adjusts the check interval based on certificate status and days left +func (s *SSLMonitoringService) adjustCheckIntervalDays(cert types.SSLCertificate, defaultIntervalDays int) int { + // Check more frequently for certificates that are expiring soon or have errors + if cert.DaysLeft <= 7 { + return 1 // Check daily for certificates expiring within 7 days + } else if cert.DaysLeft <= 30 { + // Check every 2 days for certificates expiring within 30 days + if defaultIntervalDays > 2 { + return 2 + } + } else if cert.Status == "error" { + return 1 // Check daily for certificates with errors + } + + return defaultIntervalDays +} + +func (s *SSLMonitoringService) checkSingleCertificateWithRetry(cert types.SSLCertificate) { + retryCount := s.retryQueue[cert.ID] + + log.Printf("๐Ÿ” Checking SSL certificate for domain: %s (attempt %d/%d)", + cert.Domain, retryCount+1, s.maxRetries+1) + + result, err := s.performSSLCheck(cert.Domain) + + if err != nil && retryCount < s.maxRetries { + // Increment retry count and schedule retry + s.retryQueue[cert.ID] = retryCount + 1 + log.Printf("SSL check failed for %s, will retry (%d/%d): %v", + cert.Domain, retryCount+1, s.maxRetries, err) + + // Schedule retry with exponential backoff + go func() { + backoffDuration := time.Duration((retryCount + 1) * 30) * time.Second + time.Sleep(backoffDuration) + s.checkSingleCertificateWithRetry(cert) + }() + return + } + + // Reset retry count on success or max retries reached + delete(s.retryQueue, cert.ID) + + if err != nil { + log.Printf("โŒ SSL check failed for domain %s after %d attempts: %v", + cert.Domain, s.maxRetries+1, err) + s.updateCertificateWithError(cert, err) + return + } + + // Update certificate with successful results + s.updateCertificateWithResults(cert, result) +} + +func (s *SSLMonitoringService) performSSLCheck(domain string) (*types.OperationResult, error) { + log.Printf("Performing SSL check for domain: %s", domain) + sslOp := operations.NewSSLOperation(30 * time.Second) + result, err := sslOp.Execute(domain) + + if err != nil { + log.Printf("SSL operation failed for %s: %v", domain, err) + return nil, err + } + + if result == nil { + log.Printf("SSL operation returned nil result for %s", domain) + return nil, fmt.Errorf("SSL check returned nil result") + } + + log.Printf("SSL check completed for %s: success=%v, days_left=%d", + domain, result.Success, result.SSLDaysLeft) + + return result, nil +} + +func (s *SSLMonitoringService) updateCertificateWithError(cert types.SSLCertificate, err error) { + log.Printf("Updating certificate %s with error status", cert.Domain) + + updateData := map[string]interface{}{ + "status": "error", + "updated": time.Now().Format(time.RFC3339), + "error_message": err.Error(), + } + + // Calculate next check time based on check_interval (in days) with shorter interval for errors + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 + } + // For errors, check again in half the normal interval (minimum 1 day) + errorIntervalDays := checkIntervalDays / 2 + if errorIntervalDays < 1 { + errorIntervalDays = 1 + } + + nextCheck := time.Now().Add(time.Duration(errorIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if updateErr := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); updateErr != nil { + log.Printf("Failed to update SSL certificate %s with error status: %v", cert.ID, updateErr) + } else { + log.Printf("๐Ÿ“ Updated certificate %s with error status (next check in %d days)", + cert.Domain, errorIntervalDays) + } +} + +func (s *SSLMonitoringService) updateCertificateWithResults(cert types.SSLCertificate, result *types.OperationResult) { + status := getSSLStatus(result) + + log.Printf("Updating certificate %s with results: status=%s, days_left=%d, issuer=%s", + cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer) + + updateData := map[string]interface{}{ + "status": status, + "valid_from": result.SSLValidFrom.Format(time.RFC3339), + "valid_till": result.SSLValidTill.Format(time.RFC3339), + "days_left": result.SSLDaysLeft, + "valid_days_to_expire": result.SSLDaysLeft, + "resolved_ip": result.SSLResolvedIP, + "issuer_cn": result.SSLIssuer, // Now contains organization name like "Google Trust Services" + "issued_to": result.SSLSubject, // Now contains organization name + "serial_number": result.SSLSerialNumber, + "cert_alg": result.SSLAlgorithm, + "cert_sans": result.SSLSANs, + "updated": time.Now().Format(time.RFC3339), + "error_message": "", // Clear any previous error + } + + // Calculate next check time based on check_interval (in days) and certificate status + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + nextCheck := time.Now().Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if err := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); err != nil { + log.Printf("Failed to update SSL certificate %s: %v", cert.ID, err) + } else { + log.Printf("โœ… SSL certificate updated for %s: %s (%d days left, issuer: %s, next check in %d days)", + cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer, adjustedIntervalDays) + } +} + +func getSSLStatus(result *types.OperationResult) string { + if !result.Success { + return "error" + } + + if result.SSLDaysLeft <= 0 { + return "expired" + } else if result.SSLDaysLeft <= 7 { + return "critical" // Very urgent + } else if result.SSLDaysLeft <= 30 { + return "expiring_soon" + } + + return "valid" +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_extractor.go b/server/service-operation/operations/ssl_extractor.go new file mode 100644 index 0000000..3a1ad77 --- /dev/null +++ b/server/service-operation/operations/ssl_extractor.go @@ -0,0 +1,57 @@ + +package operations + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "fmt" + "net" +) + +// extractSANs extracts Subject Alternative Names from certificate +func (op *SSLOperation) extractSANs(cert *x509.Certificate) []string { + sans := make([]string, 0) + + // Add DNS names + sans = append(sans, cert.DNSNames...) + + // Add IP addresses + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + + // Add email addresses + sans = append(sans, cert.EmailAddresses...) + + // Add URIs + for _, uri := range cert.URIs { + sans = append(sans, uri.String()) + } + + return sans +} + +// getResolvedIP resolves the domain to its IP address +func (op *SSLOperation) getResolvedIP(hostname string) string { + ips, err := net.LookupIP(hostname) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} + +// getCertificateAlgorithm returns detailed algorithm information +func (op *SSLOperation) getCertificateAlgorithm(cert *x509.Certificate) string { + algorithm := cert.SignatureAlgorithm.String() + + // Add key size information if available + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + algorithm += fmt.Sprintf(" (RSA %d-bit)", pub.N.BitLen()) + case *ecdsa.PublicKey: + algorithm += fmt.Sprintf(" (ECDSA %d-bit)", pub.Curve.Params().BitSize) + } + + return algorithm +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_operation.go b/server/service-operation/operations/ssl_operation.go new file mode 100644 index 0000000..398adbe --- /dev/null +++ b/server/service-operation/operations/ssl_operation.go @@ -0,0 +1,159 @@ + +package operations + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "time" + + "service-operation/types" +) + +type SSLOperation struct { + timeout time.Duration +} + +func NewSSLOperation(timeout time.Duration) *SSLOperation { + return &SSLOperation{ + timeout: timeout, + } +} + +func (op *SSLOperation) Execute(domain string) (*types.OperationResult, error) { + startTime := time.Now() + + // Clean and normalize domain + domain = op.normalizeDomain(domain) + + // Validate domain format + if domain == "" { + return op.createErrorResult(domain, startTime, "domain cannot be empty") + } + + // Add port if not present + host := domain + if !strings.Contains(host, ":") { + host = host + ":443" + } + + // Set up TLS connection with timeout + dialer := &net.Dialer{ + Timeout: op.timeout, + } + + // Create TLS config with proper verification + tlsConfig := &tls.Config{ + ServerName: strings.Split(host, ":")[0], + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + // Attempt TLS connection + conn, err := tls.DialWithDialer(dialer, "tcp", host, tlsConfig) + + endTime := time.Now() + responseTime := endTime.Sub(startTime) + + if err != nil { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: fmt.Sprintf("TLS connection failed: %v", err), + StartTime: startTime, + EndTime: endTime, + }, nil + } + defer conn.Close() + + // Get certificate chain information + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: "No certificates found in chain", + StartTime: startTime, + EndTime: endTime, + }, nil + } + + cert := state.PeerCertificates[0] + hostname := strings.Split(host, ":")[0] + + // Perform comprehensive certificate validation + validationError := op.validateCertificate(cert, hostname) + + // Calculate days left until expiration + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + + // Extract Subject Alternative Names + sans := op.extractSANs(cert) + + // Get resolved IP address + resolvedIP := op.getResolvedIP(hostname) + + // Extract certificate algorithm information + algorithm := op.getCertificateAlgorithm(cert) + + // Extract issuer organization (O=) instead of full distinguished name + issuerOrganization := op.extractIssuerOrganization(cert.Issuer) + + // Extract subject organization (O=) instead of full distinguished name + subjectOrganization := op.extractSubjectOrganization(cert.Subject) + + // Check if certificate is valid (not expired and passes validation) + isValid := validationError == nil && time.Now().Before(cert.NotAfter) && time.Now().After(cert.NotBefore) + + // Build detailed error message if validation failed + errorMsg := "" + if validationError != nil { + errorMsg = validationError.Error() + } else if time.Now().After(cert.NotAfter) { + errorMsg = "Certificate has expired" + } else if time.Now().Before(cert.NotBefore) { + errorMsg = "Certificate is not yet valid" + } + + // Create comprehensive result + result := &types.OperationResult{ + Type: types.OperationSSL, + Host: hostname, + Success: isValid, + ResponseTime: responseTime, + Error: errorMsg, + StartTime: startTime, + EndTime: endTime, + + // SSL specific fields - using organization instead of full DN + SSLValidFrom: cert.NotBefore, + SSLValidTill: cert.NotAfter, + SSLDaysLeft: daysLeft, + SSLIssuer: issuerOrganization, // Now shows "Google Trust Services" instead of full DN + SSLSubject: subjectOrganization, // Now shows organization instead of full DN + SSLSerialNumber: cert.SerialNumber.String(), + SSLAlgorithm: algorithm, + SSLSANs: strings.Join(sans, ","), + SSLResolvedIP: resolvedIP, + } + + return result, nil +} + +// createErrorResult creates a standardized error result +func (op *SSLOperation) createErrorResult(domain string, startTime time.Time, errorMsg string) (*types.OperationResult, error) { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: domain, + Success: false, + ResponseTime: time.Since(startTime), + Error: errorMsg, + StartTime: startTime, + EndTime: time.Now(), + }, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_utils.go b/server/service-operation/operations/ssl_utils.go new file mode 100644 index 0000000..555b4b2 --- /dev/null +++ b/server/service-operation/operations/ssl_utils.go @@ -0,0 +1,88 @@ + +package operations + +import ( + "crypto/x509/pkix" + "fmt" + "strings" +) + +// normalizeDomain cleans and normalizes the domain input +func (op *SSLOperation) normalizeDomain(domain string) string { + // Remove protocol prefixes + domain = strings.Replace(domain, "https://", "", 1) + domain = strings.Replace(domain, "http://", "", 1) + + // Remove trailing slash and path + if idx := strings.Index(domain, "/"); idx != -1 { + domain = domain[:idx] + } + + // Trim whitespace + domain = strings.TrimSpace(domain) + + return domain +} + +// formatDistinguishedName formats the certificate distinguished name +func (op *SSLOperation) formatDistinguishedName(name pkix.Name) string { + var parts []string + + if name.CommonName != "" { + parts = append(parts, fmt.Sprintf("CN=%s", name.CommonName)) + } + + for _, org := range name.Organization { + parts = append(parts, fmt.Sprintf("O=%s", org)) + } + + for _, orgUnit := range name.OrganizationalUnit { + parts = append(parts, fmt.Sprintf("OU=%s", orgUnit)) + } + + for _, country := range name.Country { + parts = append(parts, fmt.Sprintf("C=%s", country)) + } + + for _, locality := range name.Locality { + parts = append(parts, fmt.Sprintf("L=%s", locality)) + } + + for _, province := range name.Province { + parts = append(parts, fmt.Sprintf("ST=%s", province)) + } + + return strings.Join(parts, ", ") +} + +// extractIssuerOrganization extracts only the organization (O=) from the issuer distinguished name +func (op *SSLOperation) extractIssuerOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} + +// extractSubjectOrganization extracts only the organization (O=) from the subject distinguished name +func (op *SSLOperation) extractSubjectOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_validator.go b/server/service-operation/operations/ssl_validator.go new file mode 100644 index 0000000..602ea7f --- /dev/null +++ b/server/service-operation/operations/ssl_validator.go @@ -0,0 +1,65 @@ +package operations + +import ( + "crypto/x509" + "fmt" + "time" +) + +// validateCertificate performs comprehensive certificate validation +func (op *SSLOperation) validateCertificate(cert *x509.Certificate, hostname string) error { + now := time.Now() + + // Check if certificate is expired or not yet valid + if now.Before(cert.NotBefore) { + return fmt.Errorf("certificate is not yet valid (valid from: %v)", cert.NotBefore.Format("2006-01-02 15:04:05")) + } + if now.After(cert.NotAfter) { + return fmt.Errorf("certificate has expired (expired on: %v)", cert.NotAfter.Format("2006-01-02 15:04:05")) + } + + // Verify hostname matches certificate + if err := cert.VerifyHostname(hostname); err != nil { + return fmt.Errorf("hostname verification failed: %v", err) + } + + // Check key usage - certificates should have digital signature capability + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("certificate missing required digital signature key usage") + } + + // Check if certificate is self-signed (basic check) + if cert.Issuer.CommonName == cert.Subject.CommonName && len(cert.Subject.Organization) == 0 { + return fmt.Errorf("certificate appears to be self-signed") + } + + // Validate certificate chain if intermediate certificates are present + if err := op.validateCertificateChain(cert); err != nil { + return fmt.Errorf("certificate chain validation failed: %v", err) + } + + return nil +} + +// validateCertificateChain performs basic certificate chain validation +func (op *SSLOperation) validateCertificateChain(cert *x509.Certificate) error { + // Check if the certificate has proper extensions for SSL/TLS + hasServerAuth := false + for _, usage := range cert.ExtKeyUsage { + if usage == x509.ExtKeyUsageServerAuth { + hasServerAuth = true + break + } + } + + if !hasServerAuth { + return fmt.Errorf("certificate does not have server authentication extension") + } + + // Check certificate version (should be v3 for modern certificates) + if cert.Version < 3 { + return fmt.Errorf("certificate version %d is outdated (should be v3)", cert.Version) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/ssl.go b/server/service-operation/pocketbase/ssl.go new file mode 100644 index 0000000..79a4008 --- /dev/null +++ b/server/service-operation/pocketbase/ssl.go @@ -0,0 +1,89 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "service-operation/types" +) + +type SSLCertificatesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []types.SSLCertificate `json:"items"` +} + +func (c *PocketBaseClient) GetSSLCertificates() ([]types.SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records", c.baseURL) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificates: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var response SSLCertificatesResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificates response: %v", err) + } + + return response.Items, nil +} + +func (c *PocketBaseClient) UpdateSSLCertificate(id string, data map[string]interface{}) error { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal SSL certificate data: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create SSL certificate update request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update SSL certificate, status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) GetSSLCertificateByID(id string) (*types.SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var cert types.SSLCertificate + if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificate response: %v", err) + } + + return &cert, nil +} \ No newline at end of file diff --git a/server/service-operation/types/operation.go b/server/service-operation/types/operation.go index a6f81ff..f36cb0a 100644 --- a/server/service-operation/types/operation.go +++ b/server/service-operation/types/operation.go @@ -1,4 +1,3 @@ - package types import "time" @@ -10,6 +9,7 @@ const ( OperationDNS OperationType = "dns" OperationTCP OperationType = "tcp" OperationHTTP OperationType = "http" + OperationSSL OperationType = "ssl" ) type OperationRequest struct { @@ -56,6 +56,17 @@ type OperationResult struct { ContentLength int64 `json:"content_length,omitempty"` ResponseBody string `json:"response_body,omitempty"` + // SSL specific fields + SSLValidFrom time.Time `json:"ssl_valid_from,omitempty"` + SSLValidTill time.Time `json:"ssl_valid_till,omitempty"` + SSLDaysLeft int `json:"ssl_days_left,omitempty"` + SSLIssuer string `json:"ssl_issuer,omitempty"` + SSLSubject string `json:"ssl_subject,omitempty"` + SSLSerialNumber string `json:"ssl_serial_number,omitempty"` + SSLAlgorithm string `json:"ssl_algorithm,omitempty"` + SSLSANs string `json:"ssl_sans,omitempty"` + SSLResolvedIP string `json:"ssl_resolved_ip,omitempty"` + StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` } \ No newline at end of file diff --git a/server/service-operation/types/ssl.go b/server/service-operation/types/ssl.go new file mode 100644 index 0000000..02e53dd --- /dev/null +++ b/server/service-operation/types/ssl.go @@ -0,0 +1,108 @@ + +package types + +import ( + "encoding/json" + "strconv" + "time" +) + +type SSLCertificate struct { + ID string `json:"id"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + Domain string `json:"domain"` + IssuerO string `json:"issuer_o"` + Status string `json:"status"` + LastNotified string `json:"last_notified"` + WarningThreshold int `json:"warning_threshold"` + ExpiryThreshold int `json:"expiry_threshold"` + NotificationChannel string `json:"notification_channel"` + ValidFrom string `json:"valid_from"` + SerialNumber string `json:"serial_number"` // Changed to string to handle large numbers + IssuedTo string `json:"issued_to"` + ValidTill string `json:"valid_till"` + ValidityDays int `json:"validity_days"` + DaysLeft int `json:"days_left"` + ValidDaysToExpire int `json:"valid_days_to_expire"` + ResolvedIP string `json:"resolved_ip"` + IssuerCN string `json:"issuer_cn"` + CertAlg string `json:"cert_alg"` + CertSans string `json:"cert_sans"` + CheckInterval int `json:"check_interval"` + CheckAt string `json:"check_at"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// Custom unmarshaler to handle check_interval as both string and int, and serial_number as string +func (s *SSLCertificate) UnmarshalJSON(data []byte) error { + // Create a temporary struct with flexible types + type Alias SSLCertificate + aux := &struct { + CheckInterval interface{} `json:"check_interval"` + SerialNumber interface{} `json:"serial_number"` + *Alias + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle check_interval conversion + switch v := aux.CheckInterval.(type) { + case string: + if v == "" { + s.CheckInterval = 1440 // Default 24 hours in minutes + } else { + interval, err := strconv.Atoi(v) + if err != nil { + s.CheckInterval = 1440 // Default on error + } else { + s.CheckInterval = interval + } + } + case float64: + s.CheckInterval = int(v) + case int: + s.CheckInterval = v + default: + s.CheckInterval = 1440 // Default 24 hours in minutes + } + + // Handle serial_number conversion to string + switch v := aux.SerialNumber.(type) { + case string: + s.SerialNumber = v + case float64: + // Handle scientific notation by converting to string + s.SerialNumber = strconv.FormatFloat(v, 'f', 0, 64) + case int64: + s.SerialNumber = strconv.FormatInt(v, 10) + case int: + s.SerialNumber = strconv.Itoa(v) + default: + s.SerialNumber = "" + } + + return nil +} + +type SSLCheckResult struct { + Domain string `json:"domain"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ValidFrom time.Time `json:"valid_from"` + ValidTill time.Time `json:"valid_till"` + DaysLeft int `json:"days_left"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serial_number"` + Algorithm string `json:"algorithm"` + SANs []string `json:"sans"` + ResponseTime time.Duration `json:"response_time"` + ResolvedIP string `json:"resolved_ip"` + CheckedAt time.Time `json:"checked_at"` +} \ No newline at end of file