diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index 76c54cdfa..be29ae4ca 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -1,4 +1,13 @@ -import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react"; +import { + AlertCircle, + ChevronDown, + ChevronRight, + Link, + Loader2, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -12,13 +21,19 @@ import { } from "@/components/ui/card"; import { api } from "@/utils/api"; import { AddCertificate } from "./add-certificate"; -import { getCertificateChainInfo, getExpirationStatus } from "./utils"; +import { + extractLeafCommonName, + getCertificateChainExpirationDetails, + getCertificateChainInfo, + getExpirationStatus, +} from "./utils"; export const ShowCertificates = () => { const { mutateAsync, isPending: isRemoving } = api.certificates.remove.useMutation(); const { data, isPending, refetch } = api.certificates.all.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); + const [expandedChains, setExpandedChains] = useState>(new Set()); return (
@@ -66,6 +81,30 @@ export const ShowCertificates = () => { const chainInfo = getCertificateChainInfo( certificate.certificateData, ); + const commonName = extractLeafCommonName( + certificate.certificateData, + ); + const chainDetails = chainInfo.isChain + ? getCertificateChainExpirationDetails( + certificate.certificateData, + ) + : null; + const isExpanded = expandedChains.has( + certificate.certificateId, + ); + + const toggleChain = () => { + setExpandedChains((prev) => { + const next = new Set(prev); + if (next.has(certificate.certificateId)) { + next.delete(certificate.certificateId); + } else { + next.add(certificate.certificateId); + } + return next; + }); + }; + return (
{ {index + 1}. {certificate.name} + {commonName && ( + + CN: {commonName} + + )} {chainInfo.isChain && ( -
- - - Chain ({chainInfo.count}) - +
+ + {isExpanded && ( +
+ {chainDetails?.map((cert) => ( +
+ + {cert.label} + + {cert.commonName && ( + + CN: {cert.commonName} + + )} + + {cert.message} + +
+ ))} +
+ )}
)}
{ + const certRegex = + /(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g; + const matches = certData.match(certRegex); + return matches || []; +}; + export const extractExpirationDate = (certData: string): Date | null => { try { // Decode PEM base64 to DER binary @@ -94,8 +102,156 @@ export const extractExpirationDate = (certData: string): Date | null => { } }; +export const extractCommonName = (certData: string): string | null => { + try { + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const binStr = atob(b64); + const der = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + der[i] = binStr.charCodeAt(i); + } + + let offset = 0; + + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + // biome-ignore lint/style/noParameterAssign: + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + // biome-ignore lint/style/noParameterAssign: + len = (len << 8) + der[pos++]; + } + } + return { length: len, offset: pos }; + } + + // Helper: skip a field + function skipField(pos: number): number { + // biome-ignore lint/style/noParameterAssign: + pos++; + const fieldLen = readLength(pos); + return fieldLen.offset + fieldLen.length; + } + + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); + + // Check for optional version field (context-specific tag [0]) + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber + offset = skipField(offset); + + // Skip signature + offset = skipField(offset); + + // Skip issuer + offset = skipField(offset); + + // Skip validity + offset = skipField(offset); + + // Subject sequence - where we find the CN + if (der[offset++] !== 0x30) throw new Error("Expected subject sequence"); + const subjectLen = readLength(offset); + const subjectEnd = subjectLen.offset + subjectLen.length; + offset = subjectLen.offset; + + // Parse subject RDNs looking for CN (OID 2.5.4.3) + while (offset < subjectEnd) { + if (der[offset++] !== 0x31) continue; // SET + const setLen = readLength(offset); + offset = setLen.offset; + + if (der[offset++] !== 0x30) continue; // SEQUENCE + const seqLen = readLength(offset); + offset = seqLen.offset; + + if (der[offset++] !== 0x06) continue; // OID + const oidLen = readLength(offset); + offset = oidLen.offset; + + // Check if OID is 2.5.4.3 (commonName) + const oid = Array.from(der.slice(offset, offset + oidLen.length)); + offset += oidLen.length; + + // OID 2.5.4.3 in DER: [0x55, 0x04, 0x03] + if ( + oid.length === 3 && + oid[0] === 0x55 && + oid[1] === 0x04 && + oid[2] === 0x03 + ) { + // Next should be the string value + const strType = der[offset++]; + const strLen = readLength(offset); + const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length); + return new TextDecoder().decode(cnBytes); + } + } + + return null; + } catch (error) { + console.error("Error parsing certificate CN:", error); + return null; + } +}; + +// Extract the Common Name from the first (leaf) certificate in a chain +export const extractLeafCommonName = (certData: string): string | null => { + const certs = splitCertificateChain(certData); + if (certs.length === 0) return null; + return extractCommonName(certs[0]); +}; + +// Extract expiration dates from all certificates in a chain +export const extractAllExpirationDates = ( + certData: string, +): Array<{ + cert: string; + index: number; + expirationDate: Date | null; + commonName: string | null; +}> => { + const certs = splitCertificateChain(certData); + return certs.map((cert, index) => ({ + cert, + index, + expirationDate: extractExpirationDate(cert), + commonName: extractCommonName(cert), + })); +}; + +// Get the earliest expiration date from a certificate chain +export const getEarliestExpirationDate = (certData: string): Date | null => { + const expirationDates = extractAllExpirationDates(certData); + const validDates = expirationDates + .filter((item) => item.expirationDate !== null) + .map((item) => item.expirationDate as Date); + + if (validDates.length === 0) return null; + + return new Date(Math.min(...validDates.map((date) => date.getTime()))); +}; + export const getExpirationStatus = (certData: string) => { - const expirationDate = extractExpirationDate(certData); + const chainInfo = getCertificateChainInfo(certData); + const expirationDate = chainInfo.isChain + ? getEarliestExpirationDate(certData) + : extractExpirationDate(certData); if (!expirationDate) return { @@ -153,3 +309,67 @@ export const getCertificateChainInfo = (certData: string) => { count: 1, }; }; + +// Get detailed expiration information for all certificates in a chain +export const getCertificateChainExpirationDetails = (certData: string) => { + const allExpirations = extractAllExpirationDates(certData); + const now = new Date(); + + return allExpirations.map(({ index, expirationDate, commonName }) => { + if (!expirationDate) { + return { + index, + label: `Certificate ${index + 1}`, + commonName, + status: "unknown" as const, + className: "text-muted-foreground", + message: "Could not determine expiration", + expirationDate: null, + }; + } + + const daysUntilExpiration = Math.ceil( + (expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ); + + let status: "expired" | "warning" | "valid"; + let className: string; + let message: string; + + if (daysUntilExpiration < 0) { + status = "expired"; + className = "text-red-500"; + message = `Expired on ${expirationDate.toLocaleDateString([], { + year: "numeric", + month: "long", + day: "numeric", + })}`; + } else if (daysUntilExpiration <= 30) { + status = "warning"; + className = "text-yellow-500"; + message = `Expires in ${daysUntilExpiration} days`; + } else { + status = "valid"; + className = "text-muted-foreground"; + message = `Expires ${expirationDate.toLocaleDateString([], { + year: "numeric", + month: "long", + day: "numeric", + })}`; + } + + return { + index, + label: + index === 0 + ? `Certificate ${index + 1} (Leaf)` + : `Certificate ${index + 1}`, + commonName, + status, + className, + message, + expirationDate, + daysUntilExpiration, + }; + }); +};