# Bug Fix: SSL Card Shows "Waiting for 14 Certs" When All Certificates Are Loaded **Status:** Ready for Implementation **Created:** December 12, 2025 **Priority:** High (User-facing display bug) --- ## Issue Summary The SSL card on the Dashboard incorrectly shows "waiting for 14 certs" (or similar) when all SSL certificates have actually been provisioned and are available. This is a **logic bug in the "pending" certificate detection**. --- ## Root Cause Analysis ### The Current Logic In [frontend/src/components/CertificateStatusCard.tsx](../../frontend/src/components/CertificateStatusCard.tsx#L16-L19): ```tsx // Pending = hosts with ssl_forced and enabled but no certificate_id const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null) const pendingCount = sslHosts.length - hostsWithCerts.length ``` ### Why This Logic Is Wrong **The fundamental misunderstanding**: The code assumes `certificate_id` on a `ProxyHost` indicates whether that host has a valid SSL certificate. **This is incorrect for ACME (Let's Encrypt/ZeroSSL) managed certificates.** **How Caddy/Charon actually works:** 1. **ACME certificates (Let's Encrypt, ZeroSSL)**: Caddy **automatically** provisions and manages these certificates. The `certificate_id` field on `ProxyHost` is **NOT** used for ACME certs - it's only for custom uploaded certificates. 2. **Custom certificates**: Only when a user uploads a custom certificate is it stored in `ssl_certificates` table and linked via `certificate_id` on the proxy host. 3. **Backend evidence** - From [backend/internal/api/routes/routes.go](../../backend/internal/api/routes/routes.go#L70-L73): ```go // Let's Encrypt certs are auto-managed by Caddy and should not be assigned via certificate_id ``` 4. **Certificate service** - From [backend/internal/services/certificate_service.go](../../backend/internal/services/certificate_service.go#L97-L105): The certificate service scans `data/certificates/` directory for `.crt` files and syncs them to the database. These certificates are discovered **by domain name**, not by any host relationship. ### The Actual Bug When a user has: - 14 proxy hosts with `ssl_forced: true` - All 14 hosts have ACME-managed certificates (Let's Encrypt/ZeroSSL) - **None** of these hosts have `certificate_id` set (because ACME certs don't use this field) The current logic sees 14 SSL hosts with no `certificate_id` → reports "14 hosts awaiting certificate" ❌ ### Data Flow Diagram ``` ProxyHost SSLCertificate (DB) ┌─────────────────────┐ ┌──────────────────────┐ │ uuid: "abc123" │ │ domain: "example.com"│ │ domain_names: │ NO LINK │ provider: "letsencrypt" │ "example.com" │ ══════════════ │ status: "valid" │ │ ssl_forced: true │ (for ACME) │ expires_at: ... │ │ certificate_id: null│ └──────────────────────┘ └─────────────────────┘ ↓ Current logic sees certificate_id: null ↓ Incorrectly reports as "pending" ``` --- ## Correct Solution ### New Logic: Match by Domain Name Instead of checking `certificate_id`, we should check if a certificate **exists for the host's domain(s)**: ```tsx // A host is "pending" if: // 1. ssl_forced is true AND enabled is true // 2. AND there's no certificate with a matching domain // Build a set of all domains that have certificates const certifiedDomains = new Set() certificates.forEach(cert => { // Certificate domains can be comma-separated cert.domain.split(',').forEach(d => { certifiedDomains.add(d.trim().toLowerCase()) }) }) // Check each SSL host const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) const pendingHosts = sslHosts.filter(host => { // Check if ANY of the host's domains have a certificate const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) return !hostDomains.some(domain => certifiedDomains.has(domain)) }) const pendingCount = pendingHosts.length ``` --- ## Files to Modify ### Frontend Changes | File | Change | |------|--------| | [frontend/src/components/CertificateStatusCard.tsx](../../frontend/src/components/CertificateStatusCard.tsx) | Update pending detection logic to match by domain | | [frontend/src/components/**tests**/CertificateStatusCard.test.tsx](../../frontend/src/components/__tests__/CertificateStatusCard.test.tsx) | Update tests for new domain-matching logic | ### No Backend Changes Required The backend already provides: - `ProxyHost.domain_names` - comma-separated list of domains - `Certificate.domain` - the domain(s) covered by the certificate --- ## Implementation Details ### Updated CertificateStatusCard.tsx ```tsx import { useMemo } from 'react' import { Link } from 'react-router-dom' import { Loader2 } from 'lucide-react' import type { Certificate } from '../api/certificates' import type { ProxyHost } from '../api/proxyHosts' interface CertificateStatusCardProps { certificates: Certificate[] hosts: ProxyHost[] } export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) { const validCount = certificates.filter(c => c.status === 'valid').length const expiringCount = certificates.filter(c => c.status === 'expiring').length const untrustedCount = certificates.filter(c => c.status === 'untrusted').length // Build a set of all domains that have certificates (case-insensitive) const certifiedDomains = useMemo(() => { const domains = new Set() certificates.forEach(cert => { // Certificate domain field can be comma-separated cert.domain.split(',').forEach(d => { const trimmed = d.trim().toLowerCase() if (trimmed) domains.add(trimmed) }) }) return domains }, [certificates]) // Calculate pending hosts: SSL-enabled hosts without any domain covered by a certificate const { pendingCount, totalSSLHosts, hostsWithCerts } = useMemo(() => { const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) let withCerts = 0 sslHosts.forEach(host => { // Check if any of the host's domains have a certificate const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) if (hostDomains.some(domain => certifiedDomains.has(domain))) { withCerts++ } }) return { pendingCount: sslHosts.length - withCerts, totalSSLHosts: sslHosts.length, hostsWithCerts: withCerts, } }, [hosts, certifiedDomains]) const hasProvisioning = pendingCount > 0 const progressPercent = totalSSLHosts > 0 ? Math.round((hostsWithCerts / totalSSLHosts) * 100) : 100 return (
SSL Certificates
{certificates.length}
{/* Status breakdown */}
{validCount} valid {expiringCount > 0 && {expiringCount} expiring} {untrustedCount > 0 && {untrustedCount} staging}
{/* Pending indicator */} {hasProvisioning && (
{pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate
{progressPercent}% provisioned
)} ) } ``` --- ## Updated Test Cases ### Key Test Changes The existing tests rely on `certificate_id` for determining "pending" status. We need to update these to use domain-based matching. **File:** `frontend/src/components/__tests__/CertificateStatusCard.test.tsx` #### Tests to Keep (Unchanged) - `shows total certificate count` - `shows valid certificate count` - `shows expiring count when certificates are expiring` - `hides expiring count when no certificates are expiring` - `shows staging count for untrusted certificates` - `hides staging count when no untrusted certificates` - `links to certificates page` - `handles empty certificates array` - `shows spinning loader icon when pending` #### Tests to Update **Remove** tests that check `certificate_id` directly: - `shows pending indicator when hosts lack certificates` - needs domain matching - `shows plural for multiple pending hosts` - needs domain matching - `hides pending indicator when all hosts have certificates` - needs domain matching - `ignores disabled hosts when calculating pending` - update to use domain - `ignores hosts without SSL when calculating pending` - update to use domain - `calculates progress percentage correctly` - update to use domain **Add** new domain-based tests: ```tsx const mockCertWithDomain = (domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate => ({ id: Math.random(), name: domain, domain: domain, issuer: "Let's Encrypt", expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), status, provider: 'letsencrypt', }) describe('CertificateStatusCard - Domain Matching', () => { it('does not show pending when host domain matches certificate domain', () => { const certs: Certificate[] = [mockCertWithDomain('example.com')] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } ] renderWithRouter() // Should NOT show "awaiting certificate" since domain matches expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('shows pending when host domain has no matching certificate', () => { const certs: Certificate[] = [mockCertWithDomain('other.com')] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } ] renderWithRouter() expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() }) it('handles case-insensitive domain matching', () => { const certs: Certificate[] = [mockCertWithDomain('EXAMPLE.COM')] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } ] renderWithRouter() expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('handles multi-domain hosts with partial certificate coverage', () => { // Host has two domains, but only one has a certificate - should be "covered" const certs: Certificate[] = [mockCertWithDomain('example.com')] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com, www.example.com', ssl_forced: true, certificate_id: null, enabled: true } ] renderWithRouter() // Host should be considered "covered" if any domain has a cert expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('handles comma-separated certificate domains', () => { const certs: Certificate[] = [{ ...mockCertWithDomain('example.com'), domain: 'example.com, www.example.com' }] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true } ] renderWithRouter() expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('ignores disabled hosts even without certificate', () => { const certs: Certificate[] = [] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: false } ] renderWithRouter() expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('ignores hosts without SSL forced', () => { const certs: Certificate[] = [] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: false, certificate_id: null, enabled: true } ] renderWithRouter() expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() }) it('calculates progress percentage with domain matching', () => { const certs: Certificate[] = [ mockCertWithDomain('a.example.com'), mockCertWithDomain('b.example.com'), ] const hosts: ProxyHost[] = [ { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, { ...mockHost, uuid: 'h3', domain_names: 'c.example.com', ssl_forced: true, certificate_id: null, enabled: true }, { ...mockHost, uuid: 'h4', domain_names: 'd.example.com', ssl_forced: true, certificate_id: null, enabled: true }, ] renderWithRouter() // 2 out of 4 hosts have matching certs = 50% expect(screen.getByText('50% provisioned')).toBeInTheDocument() expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() }) it('shows all pending when no certificates exist', () => { const certs: Certificate[] = [] const hosts: ProxyHost[] = [ { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, ] renderWithRouter() expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() expect(screen.getByText('0% provisioned')).toBeInTheDocument() }) }) ``` --- ## Edge Cases to Handle | Scenario | Expected Behavior | |----------|-------------------| | Multiple domains per host | Covered if **any** domain has a certificate | | Case sensitivity | Case-insensitive matching (`Example.COM` = `example.com`) | | Whitespace in domain list | Trim whitespace from both sides | | Custom certificates | Hosts with `certificate_id` set should still be considered "covered" | | Disabled hosts | Ignored (not counted as pending) | | Non-SSL hosts (`ssl_forced: false`) | Ignored (not counted as pending) | | Empty domain list | Ignored | | Wildcard certs | Currently not specially handled (future enhancement) | --- ## Testing Plan ### Unit Tests (Automated) 1. ✅ Host with matching certificate domain → not pending 2. ✅ Host with no matching certificate → pending 3. ✅ Host with multiple domains, one matched → not pending 4. ✅ Case-insensitive domain matching 5. ✅ Disabled hosts ignored 6. ✅ Non-SSL hosts ignored 7. ✅ Progress percentage calculation with domain matching 8. ✅ Empty certificates list → all SSL hosts pending 9. ✅ Empty hosts list → no pending indicator ### Manual Testing 1. Import a Caddyfile with multiple hosts 2. Wait for ACME certificates to be provisioned (1-5 minutes) 3. Verify dashboard SSL card: - Shows correct number of certificates - Shows "X valid" count - Does NOT show "waiting for X certs" when all domains have certs 4. Verify the pending indicator appears only when: - A new host is added and certificate hasn't been provisioned yet - Truly no certificate exists for that domain --- ## Dashboard Polling Behavior The Dashboard already has conditional polling logic: ```tsx // Dashboard.tsx const hasPendingCerts = useMemo(() => { const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) return sslHosts.some(h => !h.certificate_id) // <-- This also needs updating }, [hosts]) const { certificates } = useCertificates({ refetchInterval: hasPendingCerts ? 15000 : false, }) ``` **Also update Dashboard.tsx** to use domain-based checking for the polling trigger: ```tsx const hasPendingCerts = useMemo(() => { // Build set of certified domains const certifiedDomains = new Set() certificates.forEach(cert => { cert.domain.split(',').forEach(d => { certifiedDomains.add(d.trim().toLowerCase()) }) }) // Check if any SSL host lacks a certificate const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) return sslHosts.some(host => { const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) return !hostDomains.some(domain => certifiedDomains.has(domain)) }) }, [hosts, certificates]) ``` --- ## Rollback Plan If issues arise, the fallback approach keeps backward compatibility: ```tsx // Fallback: host is "pending" only if: // 1. No certificate_id AND // 2. No certificate with matching domain const pendingHosts = sslHosts.filter(host => { if (host.certificate_id != null) return false // Has custom cert const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) return !hostDomains.some(domain => certifiedDomains.has(domain)) }) ``` --- ## Acceptance Criteria - [ ] SSL card does not show "waiting for X certs" when all certificates are provisioned - [ ] SSL card correctly shows pending count only for hosts without any matching certificate - [ ] Domain matching is case-insensitive - [ ] Multi-domain hosts are handled correctly - [ ] Existing custom certificate workflow still works (hosts with `certificate_id`) - [ ] Dashboard polling stops correctly once all domains have certificates - [ ] All unit tests pass - [ ] No regression in certificate status display --- ## Summary | Aspect | Current (Bug) | Fixed | |--------|---------------|-------| | Check method | `certificate_id != null` | Domain name matching | | ACME certs | Always "pending" | Correctly detected as covered | | Custom certs | Works | Still works | | Multi-domain | N/A | Covered if any domain matches | | Case sensitivity | N/A | Case-insensitive | | Polling | Never stops | Stops when all covered | **Root Cause:** Using `certificate_id` (custom cert reference) instead of domain matching for ACME certificates. **Fix:** Match host domains against certificate domains to determine coverage. **Effort:** ~1 hour implementation + testing