diff --git a/internal/certbot/manager.go b/internal/certbot/manager.go index 5a9d183..038439f 100644 --- a/internal/certbot/manager.go +++ b/internal/certbot/manager.go @@ -126,7 +126,15 @@ func (m *Manager) GetStatus(domain string) *CertStatus { return status } - for line := range strings.SplitSeq(string(out), "\n") { + parseCertOutput(string(out), status) + + return status +} + +// parseCertOutput parses the output of `openssl x509 -noout -enddate -issuer -subject` +// and populates the given CertStatus. +func parseCertOutput(output string, status *CertStatus) { + for line := range strings.SplitSeq(output, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "notAfter=") { dateStr := strings.TrimPrefix(line, "notAfter=") @@ -142,13 +150,11 @@ func (m *Manager) GetStatus(domain string) *CertStatus { status.IssuerCN = strings.TrimPrefix(line, "issuer=") } if strings.HasPrefix(line, "subject=") { - if strings.Contains(line, "CN = *.") { + if strings.Contains(line, "CN=*.") || strings.Contains(line, "CN = *.") { status.Wildcard = true } } } - - return status } // CertPath returns the fullchain.pem path for a domain. diff --git a/internal/certbot/manager_test.go b/internal/certbot/manager_test.go index 5830114..8aae25f 100644 --- a/internal/certbot/manager_test.go +++ b/internal/certbot/manager_test.go @@ -70,3 +70,53 @@ func TestEnsureCredentials_EmptyToken(t *testing.T) { t.Error("expected error for empty token") } } + +func TestParseCertOutput(t *testing.T) { + tests := []struct { + name string + output string + wildcard bool + issuer string + }{ + { + name: "wildcard cert (no spaces around =)", + output: "subject=CN=*.cloud.payne.io\nnotAfter=Sep 4 23:22:30 2026 GMT\nissuer=C=US, O=Let's Encrypt, CN=YR1\n", + wildcard: true, + issuer: "C=US, O=Let's Encrypt, CN=YR1", + }, + { + name: "wildcard cert (spaces around =)", + output: "subject=CN = *.example.com\nnotAfter=Jan 15 12:00:00 2027 GMT\nissuer=CN=R3\n", + wildcard: true, + issuer: "CN=R3", + }, + { + name: "single domain cert", + output: "subject=CN=example.com\nnotAfter=Jan 15 12:00:00 2027 GMT\nissuer=CN=R3\n", + wildcard: false, + issuer: "CN=R3", + }, + { + name: "single domain with subdomain", + output: "subject=CN=app.example.com\nnotAfter=Jan 15 12:00:00 2027 GMT\nissuer=CN=R3\n", + wildcard: false, + issuer: "CN=R3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := &CertStatus{} + parseCertOutput(tt.output, status) + if status.Wildcard != tt.wildcard { + t.Errorf("Wildcard = %v, want %v", status.Wildcard, tt.wildcard) + } + if status.IssuerCN != tt.issuer { + t.Errorf("IssuerCN = %q, want %q", status.IssuerCN, tt.issuer) + } + if status.Expiry.IsZero() { + t.Error("Expiry should be parsed") + } + }) + } +} diff --git a/web/src/components/domains/DomainTopology.tsx b/web/src/components/domains/DomainTopology.tsx index c3bfed5..82b635e 100644 --- a/web/src/components/domains/DomainTopology.tsx +++ b/web/src/components/domains/DomainTopology.tsx @@ -255,7 +255,7 @@ function FlowNode({ data }: NodeProps) { tooltip={ hasCert ? (