feat(caddy): add ListCertificates method, refactor Caddy client layout (#31)

This commit is contained in:
Pasha Sviderski committed 2026-09-30 16:43:46 +10:00
1 parent e50c5fbe57
commit 930e7ea637
23 files changed
+1112 -149

No files matched your search

+13 -1
View File
@@ -1,6 +1,18 @@
package api
import "strings"
import (
"strings"
"time"
)
// CaddyConfig is the saved Caddy configuration on a machine.
type CaddyConfig struct {
Caddyfile string
// ModifiedAt is zero when the server does not supply a modification timestamp.
ModifiedAt time.Time
// LastReconciliationError describes the latest unsuccessful configuration reconciliation.
LastReconciliationError string
}
// CaddySpec is the Caddy reverse proxy configuration for a service.
type CaddySpec struct {
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"github.com/mholt/acmez/v3/acme"
"github.com/psviderski/uncloud/api/pb"
)
// IssuedCertificate describes a certificate in Caddy's managed certificate storage.
// It does not indicate whether Caddy currently serves the certificate or whether it is trusted.
type IssuedCertificate struct {
// SAN is the Subject Alternative Name (SAN) of the certificate.
// Caddy doesn't issue certificates with multiple SANs.
SAN string
// Chain contains the parsed certificates in stored order, with the leaf first.
Chain []*x509.Certificate
// IssuerData is extra information associated with the certificate, usually provided by the issuer implementation.
IssuerData CertificateIssuerData
}
// CertificateIssuerData preserves issuer-specific metadata and provides a best-effort typed view of ACME records.
type CertificateIssuerData struct {
// Raw is the original issuer_data JSON, including unrecognized formats and fields.
Raw json.RawMessage
// ACME is populated when the metadata can be decoded as an ACME record with CA and certificate URLs.
// It is nil for absent, unrecognized, or malformed metadata.
ACME *ACMEIssuerData
}
// ACMEIssuerData identifies the ACME resources used to issue a certificate.
type ACMEIssuerData struct {
// URL is the certificate resource URL as provisioned by the ACME server.
URL string
// CA is the directory URL of the ACME CA that issued this certificate.
CA string
// Account is the URL of the account that obtained the certificate.
Account string
// RenewalInfo is the stored renewal guidance, not a guarantee of when renewal will run.
RenewalInfo *acme.RenewalInfo
}
// IssuedCertificateFromProto parses a stored certificate chain without verifying trust or expiry.
// Issuer metadata is decoded as ACME on a best-effort basis and is always preserved in its raw form.
func IssuedCertificateFromProto(p *pb.IssuedCertificate) (IssuedCertificate, error) {
if p == nil || strings.TrimSpace(p.San) == "" {
return IssuedCertificate{}, fmt.Errorf("invalid certificate: missing SAN")
}
chain, err := parseCertificateChain(p.Chain)
if err != nil {
return IssuedCertificate{}, fmt.Errorf("parse certificate '%s': %w", p.San, err)
}
return IssuedCertificate{
SAN: p.San,
Chain: chain,
IssuerData: parseCertificateIssuerData(p.IssuerData),
}, nil
}
func parseCertificateChain(data []byte) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
for len(data) > 0 {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("unexpected PEM block type '%s'", block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse X.509 certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, fmt.Errorf("empty certificate chain")
}
return chain, nil
}
func parseCertificateIssuerData(raw json.RawMessage) CertificateIssuerData {
data := CertificateIssuerData{Raw: raw}
// Recognize ACME by its resource URLs and decodable metadata.
// Unknown formats and malformed records remain raw-only.
var cert acme.Certificate
if err := json.Unmarshal(raw, &cert); err != nil || cert.CA == "" || cert.URL == "" {
return data
}
data.ACME = &ACMEIssuerData{
URL: cert.URL,
CA: cert.CA,
Account: cert.Account,
RenewalInfo: cert.RenewalInfo,
}
return data
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/mholt/acmez/v3/acme"
"github.com/psviderski/uncloud/api/pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssuedCertificateFromProto(t *testing.T) {
leaf := testCertificatePEM(t, 1)
issuer := testCertificatePEM(t, 2)
for _, tt := range []struct {
name string
chain []byte
serials []int64
}{
{name: "single certificate", chain: leaf, serials: []int64{1}},
{name: "certificate chain", chain: append(append([]byte(nil), leaf...), issuer...), serials: []int64{1, 2}},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
San: "app.example.com", Chain: tt.chain,
})
require.NoError(t, err)
assert.Equal(t, "app.example.com", cert.SAN)
require.Len(t, cert.Chain, len(tt.serials))
for i, serial := range tt.serials {
assert.Equal(t, big.NewInt(serial), cert.Chain[i].SerialNumber, "chain order must be preserved")
}
assert.Equal(t, []string{"app.example.com"}, cert.Chain[0].DNSNames)
// Expired, self-signed certificates are parsed without verifying trust or validity.
assert.Equal(t, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), cert.Chain[0].NotAfter)
assert.Empty(t, cert.IssuerData)
})
}
}
func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) {
valid := testCertificatePEM(t, 1)
invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})
for _, tt := range []struct {
name string
input *pb.IssuedCertificate
wantErr string
}{
{name: "nil input", wantErr: "missing SAN"},
{name: "missing SAN", input: &pb.IssuedCertificate{Chain: valid}, wantErr: "missing SAN"},
{name: "blank SAN", input: &pb.IssuedCertificate{San: " \t", Chain: valid}, wantErr: "missing SAN"},
{name: "empty chain", input: &pb.IssuedCertificate{San: "app.example.com"}, wantErr: "empty certificate chain"},
{name: "invalid PEM", input: &pb.IssuedCertificate{San: "app.example.com", Chain: []byte("not PEM")},
wantErr: "empty certificate chain"},
{name: "wrong PEM block type", input: &pb.IssuedCertificate{San: "app.example.com",
Chain: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("key")})},
wantErr: "unexpected PEM block type"},
{name: "invalid DER", input: &pb.IssuedCertificate{San: "app.example.com", Chain: invalidDER},
wantErr: "parse X.509 certificate"},
{name: "invalid certificate after valid leaf", input: &pb.IssuedCertificate{San: "app.example.com",
Chain: append(append([]byte(nil), valid...), invalidDER...)}, wantErr: "parse X.509 certificate"},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(tt.input)
require.ErrorContains(t, err, tt.wantErr)
assert.Empty(t, cert)
})
}
}
func TestIssuedCertificateFromProto_IssuerData(t *testing.T) {
chain := testCertificatePEM(t, 1)
retryAfter := time.Date(2029, 12, 1, 0, 0, 0, 0, time.UTC)
renewalInfo := &acme.RenewalInfo{
ExplanationURL: "https://ca.example/why",
UniqueIdentifier: "aki.serial",
RetryAfter: &retryAfter,
SelectedTime: time.Date(2030, 1, 2, 0, 0, 0, 0, time.UTC),
}
renewalInfo.SuggestedWindow.Start = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
renewalInfo.SuggestedWindow.End = time.Date(2030, 1, 3, 0, 0, 0, 0, time.UTC)
for _, tt := range []struct {
name string
raw string
want *ACMEIssuerData
}{
{
name: "ACME",
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","account":"https://ca.example/acct/1","future_field":true}`,
want: &ACMEIssuerData{
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory", Account: "https://ca.example/acct/1",
},
},
{
name: "ACME with renewal information",
raw: `{
"url": "https://ca.example/cert/1",
"ca": "https://ca.example/directory",
"account": "https://ca.example/acct/1",
"renewal_info": {
"suggestedWindow": {"start": "2030-01-01T00:00:00Z", "end": "2030-01-03T00:00:00Z"},
"explanationURL": "https://ca.example/why",
"_uniqueIdentifier": "aki.serial",
"_retryAfter": "2029-12-01T00:00:00Z",
"_selectedTime": "2030-01-02T00:00:00Z"
}
}`,
want: &ACMEIssuerData{
URL: "https://ca.example/cert/1", CA: "https://ca.example/directory",
Account: "https://ca.example/acct/1", RenewalInfo: renewalInfo,
},
},
{name: "absent"},
{name: "null", raw: "null"},
{name: "unknown issuer", raw: `{"id":"provider-id","status":"issued"}`},
{name: "non-object JSON", raw: `"provider-record"`},
{name: "missing CA", raw: `{"url":"https://ca.example/cert/1"}`},
{name: "missing certificate URL", raw: `{"ca":"https://ca.example/directory"}`},
{name: "invalid JSON", raw: "{"},
{name: "invalid field types", raw: `{"url":123,"ca":false}`},
{name: "malformed renewal information",
raw: `{"url":"https://ca.example/cert/1","ca":"https://ca.example/directory","renewal_info":{"_selectedTime":"invalid"}}`},
} {
t.Run(tt.name, func(t *testing.T) {
cert, err := IssuedCertificateFromProto(&pb.IssuedCertificate{
San: "app.example.com", Chain: chain, IssuerData: []byte(tt.raw),
})
require.NoError(t, err, "issuer metadata must not prevent certificate parsing")
require.Len(t, cert.Chain, 1)
assert.Equal(t, tt.raw, string(cert.IssuerData.Raw), "preserve original JSON including unknown fields")
assert.Equal(t, tt.want, cert.IssuerData.ACME)
})
}
}
// testCertificatePEM creates an expired, self-signed certificate for parsing tests.
func testCertificatePEM(t *testing.T, serial int64) []byte {
t.Helper()
public, private, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(serial),
DNSNames: []string{"app.example.com"},
NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}