diff --git a/cmd/uc/caddy/cert/ls.go b/cmd/uc/caddy/cert/ls.go new file mode 100644 index 00000000..33d460d4 --- /dev/null +++ b/cmd/uc/caddy/cert/ls.go @@ -0,0 +1,167 @@ +package cert + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "slices" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/caddyserver/certmagic" + "github.com/docker/go-units" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client" + "github.com/spf13/cobra" +) + +type listOptions struct { + machine string + output string +} + +func NewListCommand() *cobra.Command { + opts := listOptions{} + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List certificates in cluster storage for Caddy.", + Long: `List certificates stored in the Uncloud cluster storage for Caddy. + +Caddy must use the Uncloud storage module (https://github.com/unlabs-dev/caddy-uncloud) +configured with 'storage uncloud' in the global options for its certificates to appear here. + +This inventory does not check whether Caddy currently serves or trusts a certificate.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return list(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", + "Name or ID of the machine to read certificate storage from. (default is connected machine)") + cmd.Flags().StringVarP(&opts.output, "output", "o", "", + "Output format: 'json' or empty for a human-readable table.") + completion.MachinesFlag(cmd) + + return cmd +} + +func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { + if opts.output != "" && opts.output != "json" { + return fmt.Errorf("unsupported output format '%s' (supported: json)", opts.output) + } + + clusterClient, err := uncli.ConnectCluster(ctx) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer clusterClient.Close() + + certs, listErr := clusterClient.Caddy.ListCertificates(ctx, + client.CaddyListCertificatesOptions{Machine: opts.machine}) + if len(certs) > 0 || listErr == nil { + if err = printCertificates(os.Stdout, certs, opts.output); err != nil { + return fmt.Errorf("print Caddy certificates: %w", err) + } + } + if listErr != nil { + if len(certs) > 0 { + return fmt.Errorf("certificate list is partial: %w", listErr) + } + return fmt.Errorf("list Caddy certificates: %w", listErr) + } + + return nil +} + +func printCertificates(out io.Writer, certs []api.IssuedCertificate, output string) error { + type certificate struct { + api.IssuedCertificate + Fingerprint string + } + + items := make([]certificate, 0, len(certs)) + for _, cert := range certs { + fingerprint := cert.Fingerprint() + items = append(items, certificate{ + IssuedCertificate: cert, + Fingerprint: hex.EncodeToString(fingerprint[:]), + }) + } + slices.SortFunc(items, func(a, b certificate) int { + if cmp := strings.Compare(a.SAN, b.SAN); cmp != 0 { + return cmp + } + if cmp := a.Chain[0].NotAfter.Compare(b.Chain[0].NotAfter); cmp != 0 { + return cmp + } + return strings.Compare(a.Fingerprint, b.Fingerprint) + }) + + if output == "json" { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + if err := encoder.Encode(items); err != nil { + return fmt.Errorf("marshal certificates: %w", err) + } + return nil + } + + if len(items) == 0 { + _, err := fmt.Fprintln(out, "No Caddy certificates found in cluster storage.") + return err + } + + t := tui.NewTable() + t.Headers("ID", "NAME", "ISSUER", "EXPIRES") + for _, item := range items { + t.Row( + item.Fingerprint[:12], + item.SAN, + formatIssuer(item.IssuerData), + formatExpiry(item.Chain[0].NotAfter), + ) + } + _, err := lipgloss.Fprintln(out, t) + return err +} + +func formatIssuer(data api.CertificateIssuerData) string { + if data.ACME == nil { + return "unknown" + } + + switch data.ACME.CA { + case certmagic.LetsEncryptProductionCA: + return "Let's Encrypt" + case certmagic.LetsEncryptStagingCA: + return "Let's Encrypt (staging)" + case certmagic.ZeroSSLProductionCA: + return "ZeroSSL" + case certmagic.GoogleTrustProductionCA: + return "Google Trust Services" + case certmagic.GoogleTrustStagingCA: + return "Google Trust Services (staging)" + default: + return data.ACME.CA + } +} + +func formatExpiry(expires time.Time) string { + delta := expires.Sub(time.Now()) + if delta <= 0 { + return fmt.Sprintf("%s (expired %s ago)", expires.UTC().Format(time.DateOnly), + strings.ToLower(units.HumanDuration(-delta))) + } + return fmt.Sprintf("%s (%s)", expires.UTC().Format(time.DateOnly), + strings.ToLower(units.HumanDuration(delta))) +} diff --git a/cmd/uc/caddy/cert/ls_test.go b/cmd/uc/caddy/cert/ls_test.go new file mode 100644 index 00000000..24257705 --- /dev/null +++ b/cmd/uc/caddy/cert/ls_test.go @@ -0,0 +1,128 @@ +package cert + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/caddyserver/certmagic" + "github.com/psviderski/uncloud/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintCertificates(t *testing.T) { + now := time.Now().UTC().Truncate(time.Hour) + certs := []api.IssuedCertificate{ + testIssuedCertificate("z.example.com", "z", now.Add(48*time.Hour), certmagic.LetsEncryptProductionCA), + testIssuedCertificate("a.example.com", "old", now.Add(-48*time.Hour), certmagic.LetsEncryptStagingCA), + testIssuedCertificate("a.example.com", "new", now.Add(72*time.Hour), "https://ca.example/directory"), + testIssuedCertificate("unknown.example.com", "unknown", now.Add(96*time.Hour), ""), + } + + var table bytes.Buffer + require.NoError(t, printCertificates(&table, certs, "")) + output := table.String() + assert.Contains(t, output, "ID") + assert.Contains(t, output, "NAME") + assert.Contains(t, output, "ISSUER") + assert.Contains(t, output, "EXPIRES") + assert.Contains(t, output, now.Add(-48*time.Hour).Format(time.DateOnly)+" (expired ") + assert.Contains(t, output, now.Add(48*time.Hour).Format(time.DateOnly)+" (") + assert.Contains(t, output, "Let's Encrypt (staging)") + assert.Contains(t, output, "https://ca.example/directory") + assert.Less(t, strings.Index(output, now.Add(-48*time.Hour).Format(time.DateOnly)), + strings.Index(output, now.Add(72*time.Hour).Format(time.DateOnly))) + assert.Less(t, strings.Index(output, "a.example.com"), strings.Index(output, "z.example.com")) + assert.Contains(t, output, testFingerprint("old")[:12]) + + var encoded bytes.Buffer + require.NoError(t, printCertificates(&encoded, certs, "json")) + var items []struct { + api.IssuedCertificate + Fingerprint string + } + require.NoError(t, json.Unmarshal(encoded.Bytes(), &items)) + require.Len(t, items, 4) + assert.Equal(t, testFingerprint("old"), items[0].Fingerprint) + assert.Equal(t, "a.example.com", items[0].SAN) + require.Len(t, items[0].Chain, 1) + assert.Equal(t, []byte("old"), items[0].Chain[0].Raw) + assert.Equal(t, now.Add(-48*time.Hour), items[0].Chain[0].NotAfter) + require.NotNil(t, items[0].IssuerData.ACME) + assert.Equal(t, certmagic.LetsEncryptStagingCA, items[0].IssuerData.ACME.CA) + assert.Equal(t, testFingerprint("new"), items[1].Fingerprint) + require.NotNil(t, items[1].IssuerData.ACME) + assert.Equal(t, "https://ca.example/directory", items[1].IssuerData.ACME.CA) + assert.Nil(t, items[2].IssuerData.ACME) + assert.Equal(t, "z.example.com", items[3].SAN) +} + +func TestPrintCertificates_Empty(t *testing.T) { + for _, tt := range []struct { + output string + want string + }{ + {output: "", want: "No Caddy certificates found in cluster storage.\n"}, + {output: "json", want: "[]\n"}, + } { + var out bytes.Buffer + require.NoError(t, printCertificates(&out, nil, tt.output)) + assert.Equal(t, tt.want, out.String()) + } +} + +func TestFormatIssuer(t *testing.T) { + for _, tt := range []struct { + name string + ca string + want string + }{ + {name: "production", ca: certmagic.LetsEncryptProductionCA, want: "Let's Encrypt"}, + {name: "staging", ca: certmagic.LetsEncryptStagingCA, want: "Let's Encrypt (staging)"}, + {name: "ZeroSSL", ca: certmagic.ZeroSSLProductionCA, want: "ZeroSSL"}, + {name: "Google Trust Services", ca: certmagic.GoogleTrustProductionCA, want: "Google Trust Services"}, + {name: "Google Trust Services staging", ca: certmagic.GoogleTrustStagingCA, want: "Google Trust Services (staging)"}, + {name: "other", ca: "https://ca.example/directory", want: "https://ca.example/directory"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatIssuer(api.CertificateIssuerData{ + ACME: &api.ACMEIssuerData{CA: tt.ca}, + })) + }) + } + + assert.Equal(t, "unknown", formatIssuer(api.CertificateIssuerData{})) +} + +func TestFormatExpiry_UTC(t *testing.T) { + now := time.Now().UTC() + brisbane := time.FixedZone("AEST", 10*60*60) + expires := time.Date(now.Year(), now.Month(), now.Day()+3, 3, 0, 0, 0, brisbane) + assert.True(t, strings.HasPrefix(formatExpiry(expires), expires.UTC().Format(time.DateOnly)+" (")) + assert.NotEqual(t, expires.Format(time.DateOnly), expires.UTC().Format(time.DateOnly)) + expired := now.Add(-2 * time.Hour) + assert.True(t, strings.HasPrefix(formatExpiry(expired), expired.UTC().Format(time.DateOnly)+" (expired ")) + assert.Contains(t, formatExpiry(expired), " ago)") +} + +func testIssuedCertificate(name, raw string, expires time.Time, ca string) api.IssuedCertificate { + cert := api.IssuedCertificate{ + SAN: name, + Chain: []*x509.Certificate{{Raw: []byte(raw), NotAfter: expires}}, + } + if ca != "" { + cert.IssuerData.ACME = &api.ACMEIssuerData{CA: ca} + } + return cert +} + +func testFingerprint(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/uc/caddy/cert/root.go b/cmd/uc/caddy/cert/root.go new file mode 100644 index 00000000..0a8d262b --- /dev/null +++ b/cmd/uc/caddy/cert/root.go @@ -0,0 +1,12 @@ +package cert + +import "github.com/spf13/cobra" + +func NewRootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cert", + Short: "Inspect certificates in cluster storage for Caddy.", + } + cmd.AddCommand(NewListCommand()) + return cmd +} diff --git a/cmd/uc/caddy/root.go b/cmd/uc/caddy/root.go index ac740e59..d0b9755e 100644 --- a/cmd/uc/caddy/root.go +++ b/cmd/uc/caddy/root.go @@ -1,6 +1,7 @@ package caddy import ( + "github.com/psviderski/uncloud/cmd/uc/caddy/cert" "github.com/spf13/cobra" ) @@ -10,6 +11,7 @@ func NewRootCommand() *cobra.Command { Short: "Manage Caddy reverse proxy service.", } cmd.AddCommand( + cert.NewRootCommand(), NewConfigCommand(), NewDeployCommand(), NewLogsCommand(), diff --git a/pkg/api/caddy_certificate.go b/pkg/api/caddy_certificate.go index 3b9a62ab..5a7ea71b 100644 --- a/pkg/api/caddy_certificate.go +++ b/pkg/api/caddy_certificate.go @@ -1,6 +1,7 @@ package api import ( + "crypto/sha256" "crypto/x509" "encoding/json" "encoding/pem" @@ -23,6 +24,11 @@ type IssuedCertificate struct { IssuerData CertificateIssuerData } +// Fingerprint returns the SHA-256 fingerprint of the leaf certificate. +func (c IssuedCertificate) Fingerprint() [sha256.Size]byte { + return sha256.Sum256(c.Chain[0].Raw) +} + // 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. diff --git a/pkg/api/caddy_certificate_test.go b/pkg/api/caddy_certificate_test.go index c18c65e3..54ad4a49 100644 --- a/pkg/api/caddy_certificate_test.go +++ b/pkg/api/caddy_certificate_test.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/x509" + "encoding/hex" "encoding/pem" "math/big" "testing" @@ -45,6 +46,17 @@ func TestIssuedCertificateFromProto(t *testing.T) { } } +func TestIssuedCertificateFingerprint(t *testing.T) { + cert := IssuedCertificate{Chain: []*x509.Certificate{ + {Raw: []byte("abc")}, + {Raw: []byte("issuer")}, + }} + want, err := hex.DecodeString("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + require.NoError(t, err) + fingerprint := cert.Fingerprint() + assert.Equal(t, want, fingerprint[:]) +} + func TestIssuedCertificateFromProto_InvalidCertificate(t *testing.T) { valid := testCertificatePEM(t, 1) invalidDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")})