Created
June 11, 2025 22:58
-
-
Save joemiller/32dbe2ab82b1e47a0ade158b33f186dc to your computer and use it in GitHub Desktop.
Certificate Renewal Timeline visualizer (developed by Claude. Used to simulate CA + leaf renewal with cert-manager which does NOT clamp leaf expiration to CA expiration)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Certificate Renewal Timeline Visualizer</title> | |
<style> | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
margin: 0; | |
padding: 20px; | |
background: #f5f5f5; | |
} | |
.container { | |
max-width: 100%; | |
margin: 0 auto; | |
background: white; | |
padding: 30px; | |
border-radius: 10px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
} | |
h1 { | |
color: #333; | |
margin-bottom: 10px; | |
} | |
.description { | |
color: #666; | |
margin-bottom: 30px; | |
line-height: 1.6; | |
} | |
.controls { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
gap: 20px; | |
margin-bottom: 30px; | |
padding: 20px; | |
background: #f9f9f9; | |
border-radius: 8px; | |
} | |
.control-group { | |
display: flex; | |
flex-direction: column; | |
} | |
label { | |
font-weight: 600; | |
margin-bottom: 5px; | |
color: #555; | |
font-size: 14px; | |
} | |
input, select { | |
padding: 8px 12px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-size: 14px; | |
} | |
input[type="number"] { | |
width: 100%; | |
} | |
button { | |
padding: 10px 20px; | |
background: #4CAF50; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 14px; | |
font-weight: 600; | |
transition: background 0.2s; | |
} | |
button:hover { | |
background: #45a049; | |
} | |
.timeline-container { | |
position: relative; | |
margin: 20px 0; | |
min-height: 400px; | |
max-height: 800px; | |
overflow-x: auto; | |
overflow-y: auto; | |
border: 1px solid #e0e0e0; | |
border-radius: 8px; | |
background: #fafafa; | |
width: 100%; | |
} | |
.timeline { | |
position: relative; | |
min-width: 100%; | |
height: 100%; | |
padding: 20px; | |
} | |
.time-axis { | |
position: absolute; | |
bottom: 20px; | |
left: 60px; | |
right: 20px; | |
height: 2px; | |
background: #333; | |
} | |
.time-marker { | |
position: absolute; | |
bottom: 10px; | |
transform: translateX(-50%); | |
font-size: 11px; | |
color: #666; | |
white-space: nowrap; | |
} | |
.cert-track { | |
position: absolute; | |
left: 60px; | |
right: 20px; | |
height: 40px; | |
margin: 10px 0; | |
} | |
.cert-bar { | |
position: absolute; | |
height: 100%; | |
border-radius: 4px; | |
overflow: hidden; | |
cursor: pointer; | |
transition: opacity 0.2s; | |
} | |
.cert-bar:hover { | |
opacity: 0.8; | |
} | |
.ca-cert { | |
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%); | |
border: 2px solid #1565C0; | |
} | |
.leaf-cert { | |
background: linear-gradient(135deg, #4CAF50 0%, #388E3C 100%); | |
border: 2px solid #2E7D32; | |
} | |
.invalid-cert { | |
background: repeating-linear-gradient( | |
45deg, | |
#ff5252, | |
#ff5252 10px, | |
#ff8a80 10px, | |
#ff8a80 20px | |
); | |
border: 2px solid #d32f2f; | |
} | |
.renewal-window { | |
position: absolute; | |
right: 0; | |
top: 0; | |
bottom: 0; | |
background: rgba(255, 193, 7, 0.3); | |
border-left: 2px dashed #FFC107; | |
} | |
.cert-label { | |
position: absolute; | |
left: 10px; | |
top: 50%; | |
transform: translateY(-50%); | |
font-size: 12px; | |
font-weight: 600; | |
color: white; | |
text-shadow: 0 1px 2px rgba(0,0,0,0.3); | |
pointer-events: none; | |
} | |
.legend { | |
display: flex; | |
gap: 30px; | |
margin: 20px 0; | |
padding: 15px; | |
background: #f9f9f9; | |
border-radius: 8px; | |
flex-wrap: wrap; | |
} | |
.legend-item { | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
font-size: 14px; | |
} | |
.legend-box { | |
width: 30px; | |
height: 20px; | |
border-radius: 3px; | |
} | |
.stats { | |
margin-top: 30px; | |
padding: 20px; | |
background: #f0f0f0; | |
border-radius: 8px; | |
} | |
.stat-item { | |
margin: 10px 0; | |
font-size: 14px; | |
} | |
.warning { | |
background: #fff3cd; | |
border: 1px solid #ffeaa7; | |
padding: 15px; | |
border-radius: 8px; | |
margin: 20px 0; | |
color: #856404; | |
} | |
.error { | |
background: #f8d7da; | |
border: 1px solid #f5c6cb; | |
padding: 15px; | |
border-radius: 8px; | |
margin: 20px 0; | |
color: #721c24; | |
} | |
.tooltip { | |
position: fixed; | |
background: rgba(0, 0, 0, 0.9); | |
color: white; | |
padding: 8px 12px; | |
border-radius: 4px; | |
font-size: 12px; | |
pointer-events: none; | |
z-index: 1000; | |
white-space: nowrap; | |
display: none; | |
} | |
/* Responsive adjustments */ | |
@media (max-width: 768px) { | |
.container { | |
padding: 15px; | |
} | |
.controls { | |
grid-template-columns: 1fr; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>Certificate Renewal Timeline Visualizer</h1> | |
<div class="description"> | |
This tool visualizes certificate lifecycles and renewal behavior in cert-manager. | |
It helps identify potential issues when CA and leaf certificates have similar durations, | |
where a leaf might be renewed just before the CA expires, resulting in an invalid certificate. | |
</div> | |
<div class="controls"> | |
<div class="control-group"> | |
<label for="caDuration">CA Duration (days)</label> | |
<input type="number" id="caDuration" value="1095" min="1" max="3650"> | |
</div> | |
<div class="control-group"> | |
<label for="leafDuration">Leaf Duration (days)</label> | |
<input type="number" id="leafDuration" value="1095" min="1" max="3650"> | |
</div> | |
<div class="control-group"> | |
<label for="renewBefore">Renew Before (% of duration)</label> | |
<input type="number" id="renewBefore" value="33" min="1" max="50" step="1"> | |
</div> | |
<div class="control-group"> | |
<label for="leafCount">Number of Leaf Certs</label> | |
<input type="number" id="leafCount" value="3" min="1" max="10"> | |
</div> | |
<div class="control-group"> | |
<label for="randomness">Renewal Randomness (hours)</label> | |
<input type="number" id="randomness" value="24" min="0" max="168"> | |
</div> | |
<div class="control-group"> | |
<label for="simulationDays">Simulation Duration (days)</label> | |
<input type="number" id="simulationDays" value="1200" min="365" max="3650"> | |
</div> | |
<div class="control-group" style="align-self: flex-end;"> | |
<button onclick="runSimulation()">Run Simulation</button> | |
</div> | |
</div> | |
<div class="legend"> | |
<div class="legend-item"> | |
<div class="legend-box ca-cert"></div> | |
<span>CA Certificate</span> | |
</div> | |
<div class="legend-item"> | |
<div class="legend-box leaf-cert"></div> | |
<span>Valid Leaf Certificate</span> | |
</div> | |
<div class="legend-item"> | |
<div class="legend-box invalid-cert"></div> | |
<span>Invalid Leaf Certificate</span> | |
</div> | |
<div class="legend-item"> | |
<div class="legend-box" style="background: rgba(255, 193, 7, 0.3); border-left: 2px dashed #FFC107;"></div> | |
<span>Renewal Window</span> | |
</div> | |
</div> | |
<div id="alerts"></div> | |
<div class="timeline-container"> | |
<div class="timeline" id="timeline"> | |
<div class="time-axis"></div> | |
</div> | |
</div> | |
<div class="stats" id="stats"></div> | |
</div> | |
<div class="tooltip" id="tooltip"></div> | |
<script> | |
let certData = { | |
ca: [], | |
leaves: [] | |
}; | |
function addRandomHours(date, baseHours, randomHours) { | |
const randomOffset = (Math.random() - 0.5) * 2 * randomHours; | |
return new Date(date.getTime() + (baseHours + randomOffset) * 60 * 60 * 1000); | |
} | |
function runSimulation() { | |
const caDurationDays = parseInt(document.getElementById('caDuration').value); | |
const leafDurationDays = parseInt(document.getElementById('leafDuration').value); | |
const renewBeforePercent = parseInt(document.getElementById('renewBefore').value); | |
const leafCount = parseInt(document.getElementById('leafCount').value); | |
const randomnessHours = parseInt(document.getElementById('randomness').value); | |
const simulationDays = parseInt(document.getElementById('simulationDays').value); | |
// Clear previous data | |
certData = { ca: [], leaves: [] }; | |
// Calculate renewal windows | |
const caRenewalDays = Math.floor(caDurationDays * renewBeforePercent / 100); | |
const leafRenewalDays = Math.floor(leafDurationDays * renewBeforePercent / 100); | |
// Initialize CA certificate | |
let currentDate = new Date(); | |
let caStart = new Date(currentDate); | |
let caEnd = new Date(caStart.getTime() + caDurationDays * 24 * 60 * 60 * 1000); | |
certData.ca.push({ start: caStart, end: caEnd, renewalWindow: caRenewalDays }); | |
// Initialize leaf certificates with staggered start times | |
for (let i = 0; i < leafCount; i++) { | |
const staggerDays = Math.random() * 30; // Stagger initial certs by up to 30 days | |
const leafStart = new Date(currentDate.getTime() + staggerDays * 24 * 60 * 60 * 1000); | |
const leafEnd = new Date(leafStart.getTime() + leafDurationDays * 24 * 60 * 60 * 1000); | |
certData.leaves.push([{ | |
start: leafStart, | |
end: leafEnd, | |
renewalWindow: leafRenewalDays, | |
isValid: true, | |
signingCA: certData.ca[0], | |
signingCAIndex: 1 | |
}]); | |
} | |
// Simulate renewals | |
const endDate = new Date(currentDate.getTime() + simulationDays * 24 * 60 * 60 * 1000); | |
// Simulate CA renewals | |
let lastCA = certData.ca[0]; | |
while (lastCA.end < endDate) { | |
const renewalWindowStart = new Date(lastCA.end.getTime() - caRenewalDays * 24 * 60 * 60 * 1000); | |
const renewalTime = addRandomHours(renewalWindowStart, 0, randomnessHours); | |
const newCAStart = renewalTime; | |
const newCAEnd = new Date(newCAStart.getTime() + caDurationDays * 24 * 60 * 60 * 1000); | |
certData.ca.push({ | |
start: newCAStart, | |
end: newCAEnd, | |
renewalWindow: caRenewalDays | |
}); | |
lastCA = certData.ca[certData.ca.length - 1]; | |
} | |
// Simulate leaf renewals | |
for (let i = 0; i < leafCount; i++) { | |
let lastLeaf = certData.leaves[i][0]; | |
while (lastLeaf.end < endDate) { | |
const renewalWindowStart = new Date(lastLeaf.end.getTime() - leafRenewalDays * 24 * 60 * 60 * 1000); | |
const renewalTime = addRandomHours(renewalWindowStart, 0, randomnessHours); | |
// Find the active CA at renewal time | |
// In cert-manager, once a new CA is created, it becomes the active CA | |
// The active CA is the one with the highest index whose start time <= renewal time | |
let signingCA = null; | |
let signingCAIndex = -1; | |
for (let j = certData.ca.length - 1; j >= 0; j--) { | |
const ca = certData.ca[j]; | |
// If this CA has started by renewal time, it's the active one | |
if (ca.start <= renewalTime) { | |
signingCA = ca; | |
signingCAIndex = j; | |
break; | |
} | |
} | |
// Verify the CA is still valid at renewal time | |
if (signingCA && renewalTime > signingCA.end) { | |
console.error('Error: No valid CA at renewal time', renewalTime); | |
signingCA = null; | |
} | |
const newLeafStart = renewalTime; | |
const newLeafEnd = new Date(newLeafStart.getTime() + leafDurationDays * 24 * 60 * 60 * 1000); | |
// Check if this leaf will outlive its signing CA | |
const isValid = signingCA && newLeafEnd <= signingCA.end; | |
certData.leaves[i].push({ | |
start: newLeafStart, | |
end: newLeafEnd, | |
renewalWindow: leafRenewalDays, | |
isValid: isValid, | |
signingCA: signingCA, | |
signingCAIndex: signingCAIndex + 1 | |
}); | |
lastLeaf = certData.leaves[i][certData.leaves[i].length - 1]; | |
} | |
} | |
renderTimeline(); | |
calculateStats(); | |
} | |
function renderTimeline() { | |
const timeline = document.getElementById('timeline'); | |
timeline.innerHTML = '<div class="time-axis"></div>'; | |
if (!certData.ca.length) return; | |
const startTime = certData.ca[0].start.getTime(); | |
const endTime = Math.max( | |
...certData.ca.map(c => c.end.getTime()), | |
...certData.leaves.flat().map(c => c.end.getTime()) | |
); | |
const totalDuration = endTime - startTime; | |
// Calculate responsive timeline width - much more compact | |
const containerWidth = document.querySelector('.timeline-container').clientWidth; | |
const availableWidth = Math.max(containerWidth - 80, 800); // Minimum 800px, but try to fit container | |
const daysInSimulation = totalDuration / (24 * 60 * 60 * 1000); | |
// Use a scaling factor that makes the timeline fit reasonably | |
// Start with trying to fit in available width, but ensure minimum readability | |
let pixelsPerDay = Math.max(0.3, Math.min(1.0, availableWidth / daysInSimulation)); | |
const timelineWidth = Math.max(availableWidth, daysInSimulation * pixelsPerDay); | |
timeline.style.width = timelineWidth + 'px'; | |
// Add time markers - adjust frequency based on timeline width | |
const totalDays = daysInSimulation; | |
let markerInterval; | |
if (totalDays <= 730) { // 2 years or less | |
markerInterval = 90 * 24 * 60 * 60 * 1000; // 3 months | |
} else if (totalDays <= 1460) { // 4 years or less | |
markerInterval = 180 * 24 * 60 * 60 * 1000; // 6 months | |
} else { | |
markerInterval = 365 * 24 * 60 * 60 * 1000; // 1 year | |
} | |
const trackWidth = timelineWidth - 80; // Account for left and right padding | |
for (let time = startTime; time <= endTime; time += markerInterval) { | |
const marker = document.createElement('div'); | |
marker.className = 'time-marker'; | |
const leftOffset = 60 + ((time - startTime) / totalDuration) * trackWidth; | |
marker.style.left = leftOffset + 'px'; | |
marker.textContent = new Date(time).toLocaleDateString(); | |
timeline.appendChild(marker); | |
} | |
// Render CA certificates | |
let caTrackTop = 60; | |
// Add CA section label | |
const caLabel = document.createElement('div'); | |
caLabel.style.position = 'absolute'; | |
caLabel.style.left = '5px'; | |
caLabel.style.top = (caTrackTop - 20) + 'px'; | |
caLabel.style.fontSize = '12px'; | |
caLabel.style.fontWeight = '600'; | |
caLabel.style.color = '#666'; | |
caLabel.textContent = 'CA:'; | |
timeline.appendChild(caLabel); | |
certData.ca.forEach((cert, index) => { | |
const track = document.createElement('div'); | |
track.className = 'cert-track'; | |
track.style.top = caTrackTop + 'px'; | |
const bar = createCertBar(cert, startTime, totalDuration, 'ca-cert', `CA #${index + 1}`); | |
track.appendChild(bar); | |
timeline.appendChild(track); | |
caTrackTop += 50; | |
}); | |
// Render leaf certificates - each renewal on its own line | |
let leafTrackTop = caTrackTop + 30; | |
certData.leaves.forEach((leafChain, chainIndex) => { | |
// Add a label for this leaf chain | |
const chainLabel = document.createElement('div'); | |
chainLabel.style.position = 'absolute'; | |
chainLabel.style.left = '5px'; | |
chainLabel.style.top = leafTrackTop + 'px'; | |
chainLabel.style.fontSize = '12px'; | |
chainLabel.style.fontWeight = '600'; | |
chainLabel.style.color = '#666'; | |
chainLabel.textContent = `Leaf ${chainIndex + 1}:`; | |
timeline.appendChild(chainLabel); | |
leafChain.forEach((cert, index) => { | |
const track = document.createElement('div'); | |
track.className = 'cert-track'; | |
track.style.top = leafTrackTop + 'px'; | |
const className = cert.isValid ? 'leaf-cert' : 'invalid-cert'; | |
const bar = createCertBar(cert, startTime, totalDuration, className, `Leaf ${chainIndex + 1}.${index + 1}`); | |
track.appendChild(bar); | |
timeline.appendChild(track); | |
leafTrackTop += 50; | |
}); | |
// Add extra spacing between different leaf chains | |
leafTrackTop += 20; | |
}); | |
timeline.style.height = (leafTrackTop + 80) + 'px'; | |
} | |
function createCertBar(cert, startTime, totalDuration, className, label) { | |
const bar = document.createElement('div'); | |
bar.className = 'cert-bar ' + className; | |
const startPercent = (cert.start.getTime() - startTime) / totalDuration * 100; | |
const widthPercent = (cert.end.getTime() - cert.start.getTime()) / totalDuration * 100; | |
bar.style.left = startPercent + '%'; | |
bar.style.width = widthPercent + '%'; | |
// Add renewal window | |
const renewalWindow = document.createElement('div'); | |
renewalWindow.className = 'renewal-window'; | |
const renewalPercent = cert.renewalWindow * 24 * 60 * 60 * 1000 / (cert.end.getTime() - cert.start.getTime()) * 100; | |
renewalWindow.style.width = renewalPercent + '%'; | |
bar.appendChild(renewalWindow); | |
// Add label | |
const labelEl = document.createElement('div'); | |
labelEl.className = 'cert-label'; | |
labelEl.textContent = label; | |
bar.appendChild(labelEl); | |
// Add tooltip | |
bar.addEventListener('mouseenter', (e) => showTooltip(e, cert, label)); | |
bar.addEventListener('mousemove', (e) => moveTooltip(e)); | |
bar.addEventListener('mouseleave', hideTooltip); | |
return bar; | |
} | |
function showTooltip(event, cert, label) { | |
const tooltip = document.getElementById('tooltip'); | |
const duration = Math.floor((cert.end - cert.start) / (24 * 60 * 60 * 1000)); | |
let content = `${label}<br>`; | |
content += `Start: ${cert.start.toLocaleDateString()}<br>`; | |
content += `End: ${cert.end.toLocaleDateString()}<br>`; | |
content += `Duration: ${duration} days<br>`; | |
content += `Renewal Window: ${cert.renewalWindow} days`; | |
if (cert.signingCA) { | |
const caEnd = cert.signingCA.end.toLocaleDateString(); | |
const caIndex = cert.signingCAIndex || 'Unknown'; | |
content += `<br>Signed by: CA #${caIndex}`; | |
content += `<br>CA expires: ${caEnd}`; | |
if (!cert.isValid) { | |
content += `<br><strong style="color: #ff5252;">⚠️ Outlives CA!</strong>`; | |
} | |
} | |
tooltip.innerHTML = content; | |
tooltip.style.display = 'block'; | |
tooltip.style.left = event.clientX + 10 + 'px'; | |
tooltip.style.top = event.clientY - 30 + 'px'; | |
} | |
function moveTooltip(event) { | |
const tooltip = document.getElementById('tooltip'); | |
if (tooltip.style.display === 'block') { | |
tooltip.style.left = event.clientX + 10 + 'px'; | |
tooltip.style.top = event.clientY - 30 + 'px'; | |
} | |
} | |
function hideTooltip() { | |
document.getElementById('tooltip').style.display = 'none'; | |
} | |
function calculateStats() { | |
const stats = document.getElementById('stats'); | |
const alerts = document.getElementById('alerts'); | |
let invalidCount = 0; | |
let totalLeafCerts = 0; | |
let issues = []; | |
certData.leaves.forEach((chain, chainIndex) => { | |
chain.forEach((cert, certIndex) => { | |
totalLeafCerts++; | |
if (!cert.isValid) { | |
invalidCount++; | |
issues.push({ | |
leaf: `Leaf ${chainIndex + 1}.${certIndex + 1}`, | |
renewedAt: cert.start.toLocaleDateString(), | |
leafExpires: cert.end.toLocaleDateString(), | |
caExpires: cert.signingCA ? cert.signingCA.end.toLocaleDateString() : 'Unknown', | |
signingCA: cert.signingCAIndex || 'Unknown' | |
}); | |
} | |
}); | |
}); | |
// Generate stats HTML | |
let statsHTML = '<h3>Simulation Statistics</h3>'; | |
statsHTML += `<div class="stat-item"><strong>Total CA Certificates:</strong> ${certData.ca.length}</div>`; | |
statsHTML += `<div class="stat-item"><strong>Total Leaf Certificates:</strong> ${totalLeafCerts}</div>`; | |
statsHTML += `<div class="stat-item"><strong>Invalid Certificates:</strong> ${invalidCount} (${(invalidCount/totalLeafCerts*100).toFixed(1)}%)</div>`; | |
if (invalidCount > 0) { | |
statsHTML += '<div class="stat-item"><strong>Issues Found:</strong></div>'; | |
statsHTML += '<ul style="margin: 10px 0; font-size: 13px;">'; | |
issues.forEach(issue => { | |
statsHTML += `<li>${issue.leaf} renewed on ${issue.renewedAt}, signed by CA #${issue.signingCA}, expires ${issue.leafExpires} but CA expires ${issue.caExpires}</li>`; | |
}); | |
statsHTML += '</ul>'; | |
} | |
stats.innerHTML = statsHTML; | |
// Generate alerts | |
let alertsHTML = ''; | |
if (invalidCount > 0) { | |
alertsHTML += `<div class="error"> | |
<strong>⚠️ Critical Issue Detected:</strong> ${invalidCount} leaf certificate(s) will outlive their signing CA. | |
This will cause validation failures in your Cilium clustermesh setup. | |
</div>`; | |
} | |
const caDuration = parseInt(document.getElementById('caDuration').value); | |
const leafDuration = parseInt(document.getElementById('leafDuration').value); | |
if (leafDuration >= caDuration * 0.8) { | |
alertsHTML += `<div class="warning"> | |
<strong>⚠️ Warning:</strong> Leaf certificate duration (${leafDuration} days) is very close to CA duration (${caDuration} days). | |
This increases the risk of renewal timing issues. Consider using a much shorter leaf duration (e.g., 180-365 days). | |
</div>`; | |
} | |
alerts.innerHTML = alertsHTML; | |
} | |
// Run initial simulation | |
runSimulation(); | |
// Handle window resize | |
window.addEventListener('resize', () => { | |
if (certData.ca.length > 0) { | |
renderTimeline(); | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment