Merge pull request #52 from operacle/develop

Implement SSL certificate checker in Go instead of TS and feat: Add error message and details columns
This commit is contained in:
Tola Leng
2025-06-20 20:24:55 +07:00
committed by GitHub
34 changed files with 1770 additions and 678 deletions
@@ -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) {
<YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false}
domain={['dataMin - 10', 'dataMax + 10']}
domain={yAxisDomain}
/>
<Tooltip content={<CustomTooltip />} />
{/* Area charts for different statuses */}
{segments.up.length > 0 && (
{/* Separate area charts for each status - positioned closer together */}
<Area
type="monotone"
dataKey="value"
data={segments.up}
dataKey="upValue"
stroke="#10b981"
fillOpacity={1}
strokeWidth={2}
fillOpacity={0.4}
fill="url(#colorUp)"
connectNulls
connectNulls={false}
/>
)}
{segments.down.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.down}
dataKey="downValue"
stroke="#ef4444"
fillOpacity={1}
strokeWidth={2}
fillOpacity={0.4}
fill="url(#colorDown)"
connectNulls
connectNulls={false}
/>
)}
{segments.warning.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.warning}
dataKey="warningValue"
stroke="#f59e0b"
fillOpacity={1}
strokeWidth={2}
fillOpacity={0.4}
fill="url(#colorWarning)"
connectNulls
connectNulls={false}
/>
)}
{/* Add reference lines for paused periods */}
{chartData.map((entry, index) =>
@@ -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,
@@ -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
@@ -25,6 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Error Message</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -52,6 +54,24 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
? `${check.responseTime}ms`
: "N/A"}
</TableCell>
<TableCell className={`text-sm ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} max-w-[200px]`}>
{check.error_message ? (
<div className="truncate" title={check.error_message}>
{check.error_message}
</div>
) : (
<span className={theme === 'dark' ? 'text-gray-500' : 'text-gray-400'}>-</span>
)}
</TableCell>
<TableCell className={`text-sm ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} max-w-[250px]`}>
{check.details ? (
<div className="truncate" title={check.details}>
{check.details}
</div>
) : (
<span className={theme === 'dark' ? 'text-gray-500' : 'text-gray-400'}>-</span>
)}
</TableCell>
</TableRow>
);
})}
@@ -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,6 +121,7 @@ export const AddSSLCertificateForm = ({
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="warning_threshold"
@@ -150,6 +155,24 @@ export const AddSSLCertificateForm = ({
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="check_interval"
render={({ field }) => (
<FormItem>
<FormLabel>Check Interval (Days)</FormLabel>
<FormControl>
<Input type="number" min="1" max="30" {...field} />
</FormControl>
<FormDescription>
How often to check the SSL certificate (in days)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
@@ -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<typeof formSchema>;
@@ -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
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="warning_threshold"
@@ -157,6 +161,29 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
</FormItem>
)}
/>
<FormField
control={form.control}
name="check_interval"
render={({ field }) => (
<FormItem>
<FormLabel>Check Interval (Days)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min="1"
max="30"
placeholder="1"
/>
</FormControl>
<FormDescription>
How often to check
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(certificate)}>
<Eye className="mr-2 h-4 w-4" />
{t('view')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCheck}>
<RefreshCw className="mr-2 h-4 w-4" />
Check
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(certificate)}>
<Edit className="mr-2 h-4 w-4" />
{t('edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(certificate)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl font-bold">{t('sslCertificateDetails')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('viewDetailedInformation')} {certificate.domain}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Domain & Status Overview */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center justify-between">
<span>{certificate.domain}</span>
<SSLStatusBadge status={certificate.status} />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('status')}</p>
<SSLStatusBadge status={certificate.status} />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('daysLeft')}</p>
<Badge variant={certificate.days_left <= 7 ? 'destructive' : certificate.days_left <= 30 ? 'secondary' : 'default'}>
{certificate.days_left} {t('days')}
</Badge>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validFrom')}</p>
<p className="text-sm">
{certificate.valid_from ? new Date(certificate.valid_from).toLocaleString() : 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validUntil')}</p>
<p className="text-sm">
{certificate.valid_till ? new Date(certificate.valid_till).toLocaleString() : 'N/A'}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validityPeriod')}</p>
<p className="text-sm">{certificate.validity_days || 0} {t('days')}</p>
</div>
{certificate.resolved_ip && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('resolvedIP')}</p>
<p className="text-sm font-mono bg-muted px-2 py-1 rounded">{certificate.resolved_ip}</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Certificate Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('certificateDetails')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('issuedTo')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issued_to || 'N/A'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('issuer')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issuer_o || certificate.issuer_cn || 'N/A'}</p>
</div>
{certificate.issuer_cn && certificate.issuer_cn !== certificate.issuer_o && (
<div>
<p className="text-sm font-medium text-muted-foreground">Issuer Common Name</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issuer_cn}</p>
</div>
)}
</div>
<div className="space-y-4">
{certificate.serial_number && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('serialNumber')}</p>
<p className="text-xs font-mono bg-muted px-3 py-2 rounded break-all">{certificate.serial_number}</p>
</div>
)}
{certificate.cert_alg && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('algorithm')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.cert_alg}</p>
</div>
)}
</div>
</div>
{certificate.cert_sans && (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground mb-2">{t('subjectAlternativeNames')}</p>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm whitespace-pre-wrap">{certificate.cert_sans}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Monitoring Configuration */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('monitoringConfiguration')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('warningThreshold')}</p>
<p className="text-sm font-semibold">{certificate.warning_threshold} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('expiryThreshold')}</p>
<p className="text-sm font-semibold">{certificate.expiry_threshold} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">Check Interval</p>
<p className="text-sm font-semibold">{certificate.check_interval || 1} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('notificationChannel')}</p>
<p className="text-sm font-semibold">{certificate.notification_channel || 'N/A'}</p>
</div>
</div>
{certificate.last_notified && (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t('lastNotified')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.last_notified).toLocaleString()}</p>
</div>
)}
</CardContent>
</Card>
{/* System Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('technicalInformation')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
{certificate.check_at && (
<div>
<p className="text-sm font-medium text-muted-foreground">Next Check</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.check_at).toLocaleString()}</p>
</div>
)}
{certificate.created && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('created')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.created).toLocaleString()}</p>
</div>
)}
</div>
<div className="space-y-3">
{certificate.updated && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.updated).toLocaleString()}</p>
</div>
)}
<div>
<p className="text-sm font-medium text-muted-foreground">Certificate ID</p>
<p className="text-xs font-mono bg-muted px-3 py-2 rounded break-all">{certificate.id}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,362 +1,255 @@
import React, { useState } from "react";
import { format } from "date-fns";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell
} from "@/components/ui/table";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
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<SSLCertificate | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [certToDelete, setCertToDelete] = useState<SSLCertificate | null>(null);
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<SSLCertificate | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const formatDate = (dateString: string | undefined) => {
if (!dateString) return t('unknown');
const { data: certificates = [], isLoading, isError } = useQuery({
queryKey: ['ssl-certificates'],
queryFn: fetchSSLCertificates,
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading certificates</div>;
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,
});
const handleEditCertificate = (certificate: SSLCertificate) => {
if (onEdit) {
onEdit(certificate);
} else {
toast.error("Edit functionality not implemented yet");
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 handleDeleteCertificate = (certificate: SSLCertificate) => {
setCertToDelete(certificate);
setDeleteConfirmOpen(true);
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 confirmDelete = () => {
if (certToDelete && onDelete) {
onDelete(certToDelete);
setDeleteConfirmOpen(false);
setCertToDelete(null);
}
const openViewDialog = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setShowViewDialog(true);
};
const openEditDialog = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setShowEditDialog(true);
};
const openDeleteDialog = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setShowDeleteDialog(true);
};
return (
<>
<div className="rounded-md border relative">
{refreshingId && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
<div className="bg-background p-4 rounded-md shadow flex items-center gap-2">
<RefreshCw className="h-5 w-5 animate-spin text-primary" />
<span>{t('checkingSSLCertificate')}</span>
</div>
</div>
)}
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('domain')}</TableHead>
<TableHead>{t('issuer')}</TableHead>
<TableHead>{t('expirationDate')}</TableHead>
<TableHead>{t('daysLeft')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('lastNotified')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
<TableHead>{t('issuer')}</TableHead>
<TableHead>{t('validUntil')}</TableHead>
<TableHead>{t('daysLeft')}</TableHead>
<TableHead>Check Interval</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{certificates.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
{t('noSSLCertificates')}
</TableCell>
</TableRow>
) : (
certificates.map((certificate) => (
{certificates.map((certificate) => (
<TableRow key={certificate.id}>
<TableCell className="font-medium">{certificate.domain}</TableCell>
<TableCell>{certificate.issuer_o || t('unknown')}</TableCell>
<TableCell>
{formatDate(certificate.valid_till)}
</TableCell>
<TableCell>
{typeof certificate.days_left === 'number' ? certificate.days_left : t('unknown')}
<TableCell className="font-medium">
{certificate.domain}
</TableCell>
<TableCell>
<SSLStatusBadge status={certificate.status} />
</TableCell>
<TableCell>{certificate.issuer_o || certificate.issuer_cn || 'Unknown'}</TableCell>
<TableCell>
{certificate.last_notified
? formatDate(certificate.last_notified)
: t('never')}
{certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background border border-border">
<DropdownMenuItem
onClick={() => handleViewCertificate(certificate)}
className="cursor-pointer"
>
<Eye className="mr-2 h-4 w-4" /> {t('view')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (refreshingId === null) {
onRefresh(certificate.id);
}
}}
disabled={refreshingId !== null}
className="cursor-pointer"
>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshingId === certificate.id ? 'animate-spin text-primary' : ''}`} />
{t('check')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleEditCertificate(certificate)}
className="cursor-pointer"
>
<Edit className="mr-2 h-4 w-4" /> {t('edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteCertificate(certificate)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> {t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TableCell>
<span className={certificate.days_left <= 7 ? 'text-red-600 font-semibold' : certificate.days_left <= 30 ? 'text-yellow-600 font-semibold' : 'text-green-600'}>
{certificate.days_left} {t('days')}
</span>
</TableCell>
<TableCell>
{certificate.check_interval || 1} {t('days')}
</TableCell>
<TableCell>
<SSLCertificateActions
certificate={certificate}
onView={openViewDialog}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
/>
</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</div>
{/* SSL Certificate Details Dialog */}
<Dialog open={!!selectedCert} onOpenChange={(open) => !open && setSelectedCert(null)}>
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-xl">
{t('sslCertificateDetails')}
</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
{t('detailedInfo')} {selectedCert?.domain}
</p>
</DialogHeader>
{selectedCert && (
<div className="p-6 pt-2 space-y-4 max-h-[70vh] overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Basic Information */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('basicInformation')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('domain')}:</span>
<span>{selectedCert.domain}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('status')}:</span>
<span><SSLStatusBadge status={selectedCert.status} /></span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('issuer')}:</span>
<span>{selectedCert.issued_to || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">IP:</span>
<span>{selectedCert.resolved_ip || t('unknown')}</span>
</div>
</div>
</div>
{/* Validity */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('validity')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validFrom')}:</span>
<span>{selectedCert.valid_from ? formatDate(selectedCert.valid_from) : t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validUntil')}:</span>
<span>{formatDate(selectedCert.valid_till)}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('daysLeft')}:</span>
<span>{selectedCert.days_left}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validityDays')}:</span>
<span>{selectedCert.validity_days || t('unknown')}</span>
</div>
</div>
</div>
{/* Issuer */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('issuerInfo')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('organization')}:</span>
<span>{selectedCert.issuer_o || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('commonName')}:</span>
<span>{selectedCert.issuer_cn || t('unknown')}</span>
</div>
</div>
</div>
{/* Technical Details */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('technicalDetails')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('serialNumber')}:</span>
<span>{selectedCert.serial_number || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('algorithm')}:</span>
<span>{selectedCert.cert_alg || t('unknown')}</span>
</div>
</div>
</div>
</div>
{/* Subject Alternative Names */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('subjectAltNames')}</h3>
<div>
{selectedCert.cert_sans ? (
<p className="break-words">{selectedCert.cert_sans}</p>
) : (
<p>{t('none')}</p>
)}
</div>
</div>
{/* Monitoring Configuration */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('monitoringConfig')}</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="space-y-1">
<div className="text-muted-foreground">{t('warningThreshold')}:</div>
<div>{selectedCert.warning_threshold} {t('daysLeft').toLowerCase()}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('expiryThreshold')}:</div>
<div>{selectedCert.expiry_threshold} {t('daysLeft').toLowerCase()}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('notificationChannel')}:</div>
<div>{selectedCert.notification_channel}</div>
</div>
</div>
</div>
{/* Timestamps */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('recordInfo')}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="text-muted-foreground">{t('created')}:</div>
<div>{selectedCert.created ? formatDate(selectedCert.created) : t('unknown')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('lastUpdated')}:</div>
<div>{selectedCert.updated ? formatDate(selectedCert.updated) : t('unknown')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('lastNotification')}:</div>
<div>{selectedCert.last_notified ? formatDate(selectedCert.last_notified) : t('never')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('collectionId')}:</div>
<div>{selectedCert.collectionId || t('unknown')}</div>
</div>
</div>
</div>
{certificates.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
{t('noCertificatesFound')}
</div>
)}
</CardContent>
</Card>
<DialogFooter className="p-6 pt-0 border-t">
<Button
variant="default"
onClick={() => setSelectedCert(null)}
className="w-full sm:w-auto"
>
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* View Certificate Dialog */}
<SSLCertificateDetailDialog
certificate={selectedCertificate}
open={showViewDialog}
onOpenChange={setShowViewDialog}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
{/* Add Certificate Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('deleteSSLCertificate')}</DialogTitle>
<DialogTitle>{t('addSSLCertificate')}</DialogTitle>
<DialogDescription>
{t('addCertificateDescription')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p>{t('deleteConfirmation')} <strong>{certToDelete?.domain}</strong>?</p>
<p className="text-sm text-muted-foreground mt-2">{t('deleteWarning')}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>{t('cancel')}</Button>
<Button variant="destructive" onClick={confirmDelete}>{t('delete')}</Button>
</DialogFooter>
<AddSSLCertificateForm
onSubmit={handleAddCertificate}
onCancel={() => setShowAddDialog(false)}
isPending={isSubmitting}
/>
</DialogContent>
</Dialog>
{/* Edit Certificate Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('editSSLCertificate')}</DialogTitle>
<DialogDescription>
{t('editCertificateDescription')}
</DialogDescription>
</DialogHeader>
{selectedCertificate && (
<EditSSLCertificateForm
certificate={selectedCertificate}
onSubmit={handleEditCertificate}
onCancel={() => setShowEditDialog(false)}
isPending={isSubmitting}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Certificate Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteCertificate')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteCertificateConfirmation')} {selectedCertificate?.domain}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCertificate}
disabled={isSubmitting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -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 = () => {
<SSLCertificateStatusCards certificates={certificates} />
<div className="mt-6 flex-1 flex flex-col pb-6">
<SSLCertificatesTable
certificates={certificates}
onRefresh={handleRefreshCertificate}
refreshingId={refreshingId}
onEdit={handleEditCertificate}
onDelete={handleDeleteCertificate}
/>
<SSLCertificatesTable />
</div>
</div>
@@ -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,
@@ -37,6 +37,7 @@ export async function resumeMonitoring(serviceId: string): Promise<void> {
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,
@@ -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,
+5 -10
View File
@@ -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 type { SSLCertificate, AddSSLCertificateDto, SSLNotification } from './types';
// Export utility functions
export { normalizeDomain, createErrorResponse } from './sslCheckerUtils';
// Export checking mechanisms
export { checkWithFetch } from './sslPrimaryChecker';
// Export utility functions for SSL operations
export { calculateDaysRemaining, isValid } from './utils';
@@ -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<void> {
/**
* 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<boolean> {
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<SSLCertificate> = {
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) {
@@ -136,33 +118,3 @@ export async function checkCertificateAndNotify(certificate: SSLCertificate): Pr
return false;
}
}
/**
* Manual trigger for certificate notification test
* Useful for testing notification channels
*/
export async function testCertificateNotification(certificate: SSLCertificate): Promise<boolean> {
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;
}
}
@@ -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<SSLCertificate> => {
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);
// 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);
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;
// 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<void> => {
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<boolean> => {
/**
* 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);
+9 -35
View File
@@ -1,44 +1,10 @@
// 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;
check_interval?: number; // New field for check interval in days
}
// SSL Certificate model
@@ -62,6 +28,14 @@ export interface SSLCheckerResponse {
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
-28
View File
@@ -1,6 +1,4 @@
import { SSLCheckerResponse } from "./types";
// Calculate days remaining from expiration date
export function calculateDaysRemaining(validTo: string): number {
try {
@@ -23,29 +21,3 @@ export function isValid(validTo: string): boolean {
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
}
};
}
@@ -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
+10 -6
View File
@@ -31,22 +31,23 @@ const getCollectionForServiceType = (serviceType: string): string => {
export const uptimeService = {
async recordUptimeData(data: UptimeData): Promise<void> {
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
+70 -13
View File
@@ -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" | "pending" | "warning";
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" | "warning";
responseTime: number;
error_message?: string;
details?: string;
created?: string;
updated?: string;
}
+5 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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),
@@ -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
+15 -3
View File
@@ -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=<type>&host=<host> - Quick operation test")
log.Printf(" POST /ping - Legacy ping endpoint")
log.Printf(" GET /ping/quick?host=<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)
}()
@@ -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"
}
@@ -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
}
@@ -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
}
@@ -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"
}
@@ -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
}
@@ -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
}
+12 -1
View File
@@ -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"`
}
+108
View File
@@ -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"`
}