refactor: certificates storage

This commit is contained in:
Fernandez Ludovic 2026-01-22 19:09:35 +01:00
commit 9560c583e9
12 changed files with 513 additions and 392 deletions

View file

@ -1,354 +1,37 @@
package cmd
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/log"
"github.com/urfave/cli/v3"
"golang.org/x/net/idna"
"software.sslmate.com/src/go-pkcs12"
)
const (
baseCertificatesFolderName = "certificates"
baseArchivesFolderName = "archives"
)
const (
issuerExt = ".issuer.crt"
certExt = ".crt"
keyExt = ".key"
pemExt = ".pem"
pfxExt = ".pfx"
resourceExt = ".json"
)
// CertificatesStorage a certificates' storage.
//
// rootPath:
//
// ./.lego/certificates/
// │ └── root certificates directory
// └── "path" option
//
// archivePath:
//
// ./.lego/archives/
// │ └── archived certificates directory
// └── "path" option
type CertificatesStorage struct {
rootPath string
archivePath string
pem bool
pfx bool
pfxPassword string
pfxFormat string
filename string // Deprecated
*storage.CertificatesWriter
*storage.CertificatesReader
}
// NewCertificatesStorage create a new certificates storage.
func NewCertificatesStorage(cmd *cli.Command) *CertificatesStorage {
pfxFormat := cmd.String(flgPFXFormat)
// newCertificatesStorage create a new certificates storage.
func newCertificatesStorage(cmd *cli.Command) *CertificatesStorage {
basePath := cmd.String(flgPath)
switch pfxFormat {
case "DES", "RC2", "SHA256":
default:
log.Fatal("Invalid PFX format.", slog.String("format", pfxFormat))
config := storage.CertificatesWriterConfig{
BasePath: basePath,
PEM: cmd.Bool(flgPEM),
PFX: cmd.Bool(flgPFX),
PFXFormat: cmd.String(flgPFXPass),
PFXPassword: cmd.String(flgPFXFormat),
Filename: cmd.String(flgFilename),
}
writer, err := storage.NewCertificatesWriter(config)
if err != nil {
log.Fatal("Certificates storage initialization", log.ErrorAttr(err))
}
return &CertificatesStorage{
rootPath: filepath.Join(cmd.String(flgPath), baseCertificatesFolderName),
archivePath: filepath.Join(cmd.String(flgPath), baseArchivesFolderName),
pem: cmd.Bool(flgPEM),
pfx: cmd.Bool(flgPFX),
pfxPassword: cmd.String(flgPFXPass),
pfxFormat: pfxFormat,
filename: cmd.String(flgFilename),
CertificatesWriter: writer,
CertificatesReader: storage.NewCertificatesReader(basePath),
}
}
func (s *CertificatesStorage) CreateRootFolder() {
err := createNonExistingFolder(s.rootPath)
if err != nil {
log.Fatal("Could not check/create the root folder",
slog.String("filepath", s.rootPath),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesStorage) CreateArchiveFolder() {
err := createNonExistingFolder(s.archivePath)
if err != nil {
log.Fatal("Could not check/create the archive folder.",
slog.String("filepath", s.archivePath),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesStorage) GetRootPath() string {
return s.rootPath
}
func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) {
domain := certRes.Domain
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
err := s.WriteFile(domain, certExt, certRes.Certificate)
if err != nil {
log.Fatal("Unable to save Certificate.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
if certRes.IssuerCertificate != nil {
err = s.WriteFile(domain, issuerExt, certRes.IssuerCertificate)
if err != nil {
log.Fatal("Unable to save IssuerCertificate.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
}
// if we were given a CSR, we don't know the private key
if certRes.PrivateKey != nil {
err = s.WriteCertificateFiles(domain, certRes)
if err != nil {
log.Fatal("Unable to save PrivateKey.", log.DomainAttr(domain), log.ErrorAttr(err))
}
} else if s.pem || s.pfx {
// we don't have the private key; can't write the .pem or .pfx file
log.Fatal("Unable to save PEM or PFX without the private key. Are you using a CSR?", log.DomainAttr(domain))
}
jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
if err != nil {
log.Fatal("Unable to marshal CertResource.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
err = s.WriteFile(domain, resourceExt, jsonBytes)
if err != nil {
log.Fatal("Unable to save CertResource.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesStorage) ReadResource(domain string) certificate.Resource {
raw, err := s.ReadFile(domain, resourceExt)
if err != nil {
log.Fatal("Error while loading the metadata.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
var resource certificate.Resource
if err = json.Unmarshal(raw, &resource); err != nil {
log.Fatal("Error while marshaling the metadata.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
return resource
}
func (s *CertificatesStorage) ExistsFile(domain, extension string) bool {
filePath := s.GetFileName(domain, extension)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return false
} else if err != nil {
log.Fatal("File stat", slog.String("filepath", filePath), log.ErrorAttr(err))
}
return true
}
func (s *CertificatesStorage) ReadFile(domain, extension string) ([]byte, error) {
return os.ReadFile(s.GetFileName(domain, extension))
}
func (s *CertificatesStorage) GetFileName(domain, extension string) string {
filename := sanitizedDomain(domain) + extension
return filepath.Join(s.rootPath, filename)
}
func (s *CertificatesStorage) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) {
content, err := s.ReadFile(domain, extension)
if err != nil {
return nil, err
}
// The input may be a bundle or a single certificate.
return certcrypto.ParsePEMBundle(content)
}
func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) error {
var baseFileName string
if s.filename != "" {
baseFileName = s.filename
} else {
baseFileName = sanitizedDomain(domain)
}
filePath := filepath.Join(s.rootPath, baseFileName+extension)
return os.WriteFile(filePath, data, filePerm)
}
func (s *CertificatesStorage) WriteCertificateFiles(domain string, certRes *certificate.Resource) error {
err := s.WriteFile(domain, keyExt, certRes.PrivateKey)
if err != nil {
return fmt.Errorf("unable to save key file: %w", err)
}
if s.pem {
err = s.WriteFile(domain, pemExt, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
if err != nil {
return fmt.Errorf("unable to save PEM file: %w", err)
}
}
if s.pfx {
err = s.WritePFXFile(domain, certRes)
if err != nil {
return fmt.Errorf("unable to save PFX file: %w", err)
}
}
return nil
}
func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.Resource) error {
certPemBlock, _ := pem.Decode(certRes.Certificate)
if certPemBlock == nil {
return fmt.Errorf("unable to parse Certificate for domain %s", domain)
}
cert, err := x509.ParseCertificate(certPemBlock.Bytes)
if err != nil {
return fmt.Errorf("unable to load Certificate for domain %s: %w", domain, err)
}
certChain, err := getCertificateChain(certRes)
if err != nil {
return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err)
}
privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
if err != nil {
return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err)
}
encoder, err := getPFXEncoder(s.pfxFormat)
if err != nil {
return fmt.Errorf("PFX encoder: %w", err)
}
pfxBytes, err := encoder.Encode(privateKey, cert, certChain, s.pfxPassword)
if err != nil {
return fmt.Errorf("unable to encode PFX data for domain %s: %w", domain, err)
}
return s.WriteFile(domain, pfxExt, pfxBytes)
}
func (s *CertificatesStorage) MoveToArchive(domain string) error {
baseFilename := filepath.Join(s.rootPath, sanitizedDomain(domain))
matches, err := filepath.Glob(baseFilename + ".*")
if err != nil {
return err
}
for _, oldFile := range matches {
if strings.TrimSuffix(oldFile, filepath.Ext(oldFile)) != baseFilename && oldFile != baseFilename+issuerExt {
continue
}
date := strconv.FormatInt(time.Now().Unix(), 10)
filename := date + "." + filepath.Base(oldFile)
newFile := filepath.Join(s.archivePath, filename)
err = os.Rename(oldFile, newFile)
if err != nil {
return err
}
}
return nil
}
func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, error) {
chainCertPemBlock, rest := pem.Decode(certRes.IssuerCertificate)
if chainCertPemBlock == nil {
return nil, errors.New("unable to parse Issuer Certificate")
}
var certChain []*x509.Certificate
for chainCertPemBlock != nil {
chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("unable to parse Chain Certificate: %w", err)
}
certChain = append(certChain, chainCert)
chainCertPemBlock, rest = pem.Decode(rest) // Try decoding the next pem block
}
return certChain, nil
}
func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) {
var encoder *pkcs12.Encoder
switch pfxFormat {
case "SHA256":
encoder = pkcs12.Modern2023
case "DES":
encoder = pkcs12.LegacyDES
case "RC2":
encoder = pkcs12.LegacyRC2
default:
return nil, fmt.Errorf("invalid PFX format: %s", pfxFormat)
}
return encoder, nil
}
// sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)).
func sanitizedDomain(domain string) string {
safe, err := idna.ToASCII(strings.NewReplacer(":", "-", "*", "_").Replace(domain))
if err != nil {
log.Fatal("Could not sanitize the domain.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
return safe
}

View file

@ -6,32 +6,53 @@ import (
"regexp"
"testing"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mockCertificateStorage(t *testing.T) *CertificatesStorage {
t.Helper()
basePath := t.TempDir()
writer, err := storage.NewCertificatesWriter(
storage.CertificatesWriterConfig{
BasePath: basePath,
},
)
require.NoError(t, err)
certStorage := &CertificatesStorage{
CertificatesWriter: writer,
CertificatesReader: storage.NewCertificatesReader(basePath),
}
certStorage.CreateRootFolder()
certStorage.CreateArchiveFolder()
return certStorage
}
func TestCertificatesStorage_MoveToArchive(t *testing.T) {
domain := "example.com"
storage := CertificatesStorage{
rootPath: t.TempDir(),
archivePath: t.TempDir(),
}
certStorage := mockCertificateStorage(t)
domainFiles := generateTestFiles(t, storage.rootPath, domain)
domainFiles := generateTestFiles(t, certStorage.GetRootPath(), domain)
err := storage.MoveToArchive(domain)
err := certStorage.MoveToArchive(domain)
require.NoError(t, err)
for _, file := range domainFiles {
assert.NoFileExists(t, file)
}
root, err := os.ReadDir(storage.rootPath)
root, err := os.ReadDir(certStorage.GetRootPath())
require.NoError(t, err)
require.Empty(t, root)
archive, err := os.ReadDir(storage.archivePath)
archive, err := os.ReadDir(certStorage.GetArchivePath())
require.NoError(t, err)
require.Len(t, archive, len(domainFiles))
@ -41,25 +62,22 @@ func TestCertificatesStorage_MoveToArchive(t *testing.T) {
func TestCertificatesStorage_MoveToArchive_noFileRelatedToDomain(t *testing.T) {
domain := "example.com"
storage := CertificatesStorage{
rootPath: t.TempDir(),
archivePath: t.TempDir(),
}
certStorage := mockCertificateStorage(t)
domainFiles := generateTestFiles(t, storage.rootPath, "example.org")
domainFiles := generateTestFiles(t, certStorage.GetRootPath(), "example.org")
err := storage.MoveToArchive(domain)
err := certStorage.MoveToArchive(domain)
require.NoError(t, err)
for _, file := range domainFiles {
assert.FileExists(t, file)
}
root, err := os.ReadDir(storage.rootPath)
root, err := os.ReadDir(certStorage.GetRootPath())
require.NoError(t, err)
assert.Len(t, root, len(domainFiles))
archive, err := os.ReadDir(storage.archivePath)
archive, err := os.ReadDir(certStorage.GetArchivePath())
require.NoError(t, err)
assert.Empty(t, archive)
@ -68,15 +86,12 @@ func TestCertificatesStorage_MoveToArchive_noFileRelatedToDomain(t *testing.T) {
func TestCertificatesStorage_MoveToArchive_ambiguousDomain(t *testing.T) {
domain := "example.com"
storage := CertificatesStorage{
rootPath: t.TempDir(),
archivePath: t.TempDir(),
}
certStorage := mockCertificateStorage(t)
domainFiles := generateTestFiles(t, storage.rootPath, domain)
otherDomainFiles := generateTestFiles(t, storage.rootPath, domain+".example.org")
domainFiles := generateTestFiles(t, certStorage.GetRootPath(), domain)
otherDomainFiles := generateTestFiles(t, certStorage.GetRootPath(), domain+".example.org")
err := storage.MoveToArchive(domain)
err := certStorage.MoveToArchive(domain)
require.NoError(t, err)
for _, file := range domainFiles {
@ -87,11 +102,11 @@ func TestCertificatesStorage_MoveToArchive_ambiguousDomain(t *testing.T) {
assert.FileExists(t, file)
}
root, err := os.ReadDir(storage.rootPath)
root, err := os.ReadDir(certStorage.GetRootPath())
require.NoError(t, err)
require.Len(t, root, len(otherDomainFiles))
archive, err := os.ReadDir(storage.archivePath)
archive, err := os.ReadDir(certStorage.GetArchivePath())
require.NoError(t, err)
require.Len(t, archive, len(domainFiles))
@ -103,7 +118,7 @@ func generateTestFiles(t *testing.T, dir, domain string) []string {
var filenames []string
for _, ext := range []string{issuerExt, certExt, keyExt, pemExt, pfxExt, resourceExt} {
for _, ext := range []string{storage.IssuerExt, storage.CertExt, storage.KeyExt, storage.PEMExt, storage.PFXExt, storage.ResourceExt} {
filename := filepath.Join(dir, domain+ext)
err := os.WriteFile(filename, []byte("test"), 0o666)
require.NoError(t, err)

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/urfave/cli/v3"
)
@ -54,7 +55,7 @@ func list(ctx context.Context, cmd *cli.Command) error {
}
func listCertificates(_ context.Context, cmd *cli.Command) error {
certsStorage := NewCertificatesStorage(cmd)
certsStorage := storage.NewCertificatesReader(cmd.String(flgPath))
matches, err := filepath.Glob(filepath.Join(certsStorage.GetRootPath(), "*.crt"))
if err != nil {
@ -76,7 +77,7 @@ func listCertificates(_ context.Context, cmd *cli.Command) error {
}
for _, filename := range matches {
if strings.HasSuffix(filename, issuerExt) {
if strings.HasSuffix(filename, storage.IssuerExt) {
continue
}

View file

@ -157,7 +157,7 @@ func renew(ctx context.Context, cmd *cli.Command) error {
log.Fatal("The account is not registered. Use 'run' to register a new account.", slog.String("email", account.Email))
}
certsStorage := NewCertificatesStorage(cmd)
certsStorage := newCertificatesStorage(cmd)
bundle := !cmd.Bool(flgNoBundle)
@ -181,7 +181,7 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, account *Account, ke
// load the cert resource from files.
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
certificates, err := certsStorage.ReadCertificate(domain, certExt)
certificates, err := certsStorage.ReadCertificate(domain, storage.CertExt)
if err != nil {
log.Fatal("Error while loading the certificate.", log.DomainAttr(domain), log.ErrorAttr(err))
}
@ -242,7 +242,7 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, account *Account, ke
var privateKey crypto.PrivateKey
if cmd.Bool(flgReuseKey) {
keyBytes, errR := certsStorage.ReadFile(domain, keyExt)
keyBytes, errR := certsStorage.ReadFile(domain, storage.KeyExt)
if errR != nil {
log.Fatal("Error while loading the private key.",
log.DomainAttr(domain),
@ -322,7 +322,7 @@ func renewForCSR(ctx context.Context, cmd *cli.Command, account *Account, keyTyp
// load the cert resource from files.
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
certificates, err := certsStorage.ReadCertificate(domain, certExt)
certificates, err := certsStorage.ReadCertificate(domain, storage.CertExt)
if err != nil {
log.Fatal("Error while loading the certificate.",
log.DomainAttr(domain),

View file

@ -57,13 +57,13 @@ func revoke(ctx context.Context, cmd *cli.Command) error {
client := newClient(cmd, account, keyType)
certsStorage := NewCertificatesStorage(cmd)
certsStorage := newCertificatesStorage(cmd)
certsStorage.CreateRootFolder()
for _, domain := range cmd.StringSlice(flgDomains) {
log.Info("Trying to revoke the certificate.", log.DomainAttr(domain))
certBytes, err := certsStorage.ReadFile(domain, certExt)
certBytes, err := certsStorage.ReadFile(domain, storage.CertExt)
if err != nil {
log.Fatal("Error while revoking the certificate.", log.DomainAttr(domain), log.ErrorAttr(err))
}

View file

@ -144,7 +144,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
fmt.Printf(rootPathWarningMessage, accountsStorage.GetRootPath())
}
certsStorage := NewCertificatesStorage(cmd)
certsStorage := newCertificatesStorage(cmd)
certsStorage.CreateRootFolder()
cert, err := obtainCertificate(ctx, cmd, client)
@ -239,7 +239,7 @@ func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Clien
if cmd.IsSet(flgPrivateKey) {
var err error
request.PrivateKey, err = loadPrivateKey(cmd.String(flgPrivateKey))
request.PrivateKey, err = storage.LoadPrivateKey(cmd.String(flgPrivateKey))
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}
@ -268,7 +268,7 @@ func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Clien
if cmd.IsSet(flgPrivateKey) {
var err error
request.PrivateKey, err = loadPrivateKey(cmd.String(flgPrivateKey))
request.PrivateKey, err = storage.LoadPrivateKey(cmd.String(flgPrivateKey))
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
"github.com/urfave/cli/v3"
"software.sslmate.com/src/go-pkcs12"
@ -266,7 +267,7 @@ func CreatePathFlag(defaultPath string, forceCreation bool) cli.Flag {
return nil
}
err := createNonExistingFolder(s)
err := storage.CreateNonExistingFolder(s)
if err != nil {
return fmt.Errorf("could not check/create the path %q: %w", s, err)
}

View file

@ -11,6 +11,7 @@ import (
"time"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
)
const (
@ -87,18 +88,18 @@ func metaToEnv(meta map[string]string) []string {
func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) {
meta[hookEnvCertDomain] = domain
meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt)
meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt)
meta[hookEnvCertPath] = certsStorage.GetFileName(domain, storage.CertExt)
meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, storage.KeyExt)
if certRes.IssuerCertificate != nil {
meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt)
meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, storage.IssuerExt)
}
if certsStorage.pem {
meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt)
if certsStorage.IsPEM() {
meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, storage.PEMExt)
}
if certsStorage.pfx {
meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt)
if certsStorage.IsPFX() {
meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, storage.PFXExt)
}
}

View file

@ -0,0 +1,38 @@
package storage
import (
"os"
"path/filepath"
)
const (
IssuerExt = ".issuer.crt"
CertExt = ".crt"
KeyExt = ".key"
PEMExt = ".pem"
PFXExt = ".pfx"
ResourceExt = ".json"
)
const (
baseCertificatesFolderName = "certificates"
baseArchivesFolderName = "archives"
)
func getCertificatesRootPath(basePath string) string {
return filepath.Join(basePath, baseCertificatesFolderName)
}
func getCertificatesArchivePath(basePath string) string {
return filepath.Join(basePath, baseArchivesFolderName)
}
func CreateNonExistingFolder(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return os.MkdirAll(path, 0o700)
} else if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,93 @@
package storage
import (
"crypto/x509"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/log"
"golang.org/x/net/idna"
)
type CertificatesReader struct {
rootPath string
}
func NewCertificatesReader(basePath string) *CertificatesReader {
return &CertificatesReader{
rootPath: getCertificatesRootPath(basePath),
}
}
func (s *CertificatesReader) ReadResource(domain string) certificate.Resource {
raw, err := s.ReadFile(domain, ResourceExt)
if err != nil {
log.Fatal("Error while loading the metadata.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
var resource certificate.Resource
if err = json.Unmarshal(raw, &resource); err != nil {
log.Fatal("Error while marshaling the metadata.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
return resource
}
func (s *CertificatesReader) ExistsFile(domain, extension string) bool {
filePath := s.GetFileName(domain, extension)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return false
} else if err != nil {
log.Fatal("File stat", slog.String("filepath", filePath), log.ErrorAttr(err))
}
return true
}
func (s *CertificatesReader) ReadFile(domain, extension string) ([]byte, error) {
return os.ReadFile(s.GetFileName(domain, extension))
}
func (s *CertificatesReader) GetRootPath() string {
return s.rootPath
}
func (s *CertificatesReader) GetFileName(domain, extension string) string {
filename := sanitizedDomain(domain) + extension
return filepath.Join(s.rootPath, filename)
}
func (s *CertificatesReader) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) {
content, err := s.ReadFile(domain, extension)
if err != nil {
return nil, err
}
// The input may be a bundle or a single certificate.
return certcrypto.ParsePEMBundle(content)
}
// sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)).
func sanitizedDomain(domain string) string {
safe, err := idna.ToASCII(strings.NewReplacer(":", "-", "*", "_").Replace(domain))
if err != nil {
log.Fatal("Could not sanitize the domain.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
return safe
}

View file

@ -0,0 +1,301 @@
package storage
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/log"
"software.sslmate.com/src/go-pkcs12"
)
const filePerm os.FileMode = 0o600
type CertificatesWriterConfig struct {
BasePath string
PEM bool
PFX bool
PFXFormat string
PFXPassword string
Filename string // FIXME
}
// CertificatesWriter a writer of certificate files.
//
// rootPath:
//
// ./.lego/certificates/
// │ └── root certificates directory
// └── "path" option
//
// archivePath:
//
// ./.lego/archives/
// │ └── archived certificates directory
// └── "path" option
type CertificatesWriter struct {
rootPath string
archivePath string
pem bool
pfx bool
pfxFormat string
pfxPassword string
filename string // FIXME
}
// NewCertificatesWriter create a new certificates storage writer.
func NewCertificatesWriter(config CertificatesWriterConfig) (*CertificatesWriter, error) {
if config.PFX {
switch config.PFXFormat {
case "DES", "RC2", "SHA256":
default:
return nil, fmt.Errorf("invalid PFX format: %s", config.PFXFormat)
}
}
return &CertificatesWriter{
rootPath: getCertificatesRootPath(config.BasePath),
archivePath: getCertificatesArchivePath(config.BasePath),
pem: config.PEM,
pfx: config.PFX,
pfxPassword: config.PFXPassword,
pfxFormat: config.PFXFormat,
filename: config.Filename,
}, nil
}
func (s *CertificatesWriter) CreateRootFolder() {
err := CreateNonExistingFolder(s.rootPath)
if err != nil {
log.Fatal("Could not check/create the root folder",
slog.String("filepath", s.rootPath),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesWriter) CreateArchiveFolder() {
err := CreateNonExistingFolder(s.archivePath)
if err != nil {
log.Fatal("Could not check/create the archive folder.",
slog.String("filepath", s.archivePath),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesWriter) SaveResource(certRes *certificate.Resource) {
domain := certRes.Domain
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
err := s.writeFile(domain, CertExt, certRes.Certificate)
if err != nil {
log.Fatal("Unable to save Certificate.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
if certRes.IssuerCertificate != nil {
err = s.writeFile(domain, IssuerExt, certRes.IssuerCertificate)
if err != nil {
log.Fatal("Unable to save IssuerCertificate.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
}
// if we were given a CSR, we don't know the private key
if certRes.PrivateKey != nil {
err = s.writeCertificateFiles(domain, certRes)
if err != nil {
log.Fatal("Unable to save PrivateKey.", log.DomainAttr(domain), log.ErrorAttr(err))
}
} else if s.pem || s.pfx {
// we don't have the private key; can't write the .pem or .pfx file
log.Fatal("Unable to save PEM or PFX without the private key. Are you using a CSR?", log.DomainAttr(domain))
}
jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
if err != nil {
log.Fatal("Unable to marshal CertResource.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
err = s.writeFile(domain, ResourceExt, jsonBytes)
if err != nil {
log.Fatal("Unable to save CertResource.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
}
}
func (s *CertificatesWriter) MoveToArchive(domain string) error {
baseFilename := filepath.Join(s.rootPath, sanitizedDomain(domain))
matches, err := filepath.Glob(baseFilename + ".*")
if err != nil {
return err
}
for _, oldFile := range matches {
if strings.TrimSuffix(oldFile, filepath.Ext(oldFile)) != baseFilename && oldFile != baseFilename+IssuerExt {
continue
}
date := strconv.FormatInt(time.Now().Unix(), 10)
filename := date + "." + filepath.Base(oldFile)
newFile := filepath.Join(s.archivePath, filename)
err = os.Rename(oldFile, newFile)
if err != nil {
return err
}
}
return nil
}
func (s *CertificatesWriter) GetArchivePath() string {
return s.archivePath
}
func (s *CertificatesWriter) IsPEM() bool {
return s.pem
}
func (s *CertificatesWriter) IsPFX() bool {
return s.pfx
}
func (s *CertificatesWriter) writeCertificateFiles(domain string, certRes *certificate.Resource) error {
err := s.writeFile(domain, KeyExt, certRes.PrivateKey)
if err != nil {
return fmt.Errorf("unable to save key file: %w", err)
}
if s.pem {
err = s.writeFile(domain, PEMExt, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
if err != nil {
return fmt.Errorf("unable to save PEM file: %w", err)
}
}
if s.pfx {
err = s.writePFXFile(domain, certRes)
if err != nil {
return fmt.Errorf("unable to save PFX file: %w", err)
}
}
return nil
}
func (s *CertificatesWriter) writePFXFile(domain string, certRes *certificate.Resource) error {
certPemBlock, _ := pem.Decode(certRes.Certificate)
if certPemBlock == nil {
return fmt.Errorf("unable to parse Certificate for domain %s", domain)
}
cert, err := x509.ParseCertificate(certPemBlock.Bytes)
if err != nil {
return fmt.Errorf("unable to load Certificate for domain %s: %w", domain, err)
}
certChain, err := getCertificateChain(certRes)
if err != nil {
return fmt.Errorf("unable to get certificate chain for domain %s: %w", domain, err)
}
privateKey, err := certcrypto.ParsePEMPrivateKey(certRes.PrivateKey)
if err != nil {
return fmt.Errorf("unable to parse PrivateKey for domain %s: %w", domain, err)
}
encoder, err := getPFXEncoder(s.pfxFormat)
if err != nil {
return fmt.Errorf("PFX encoder: %w", err)
}
pfxBytes, err := encoder.Encode(privateKey, cert, certChain, s.pfxPassword)
if err != nil {
return fmt.Errorf("unable to encode PFX data for domain %s: %w", domain, err)
}
return s.writeFile(domain, PFXExt, pfxBytes)
}
func (s *CertificatesWriter) writeFile(domain, extension string, data []byte) error {
var baseFileName string
if s.filename != "" {
baseFileName = s.filename
} else {
baseFileName = sanitizedDomain(domain)
}
filePath := filepath.Join(s.rootPath, baseFileName+extension)
log.Info("Writing file.",
slog.String("filepath", filePath))
return os.WriteFile(filePath, data, filePerm)
}
func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, error) {
chainCertPemBlock, rest := pem.Decode(certRes.IssuerCertificate)
if chainCertPemBlock == nil {
return nil, errors.New("unable to parse Issuer Certificate")
}
var certChain []*x509.Certificate
for chainCertPemBlock != nil {
chainCert, err := x509.ParseCertificate(chainCertPemBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("unable to parse Chain Certificate: %w", err)
}
certChain = append(certChain, chainCert)
chainCertPemBlock, rest = pem.Decode(rest) // Try decoding the next pem block
}
return certChain, nil
}
func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) {
var encoder *pkcs12.Encoder
switch pfxFormat {
case "SHA256":
encoder = pkcs12.Modern2023
case "DES":
encoder = pkcs12.LegacyDES
case "RC2":
encoder = pkcs12.LegacyRC2
default:
return nil, fmt.Errorf("invalid PFX format: %s", pfxFormat)
}
return encoder, nil
}

View file

@ -22,8 +22,6 @@ import (
"github.com/urfave/cli/v3"
)
const filePerm os.FileMode = 0o600
// setupClient creates a new client with challenge settings.
func setupClient(cmd *cli.Command, account *Account, keyType certcrypto.KeyType) *lego.Client {
client := newClient(cmd, account, keyType)
@ -123,16 +121,6 @@ func getUserAgent(cmd *cli.Command) string {
return strings.TrimSpace(fmt.Sprintf("%s lego-cli/%s", cmd.String(flgUserAgent), cmd.Version))
}
func createNonExistingFolder(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return os.MkdirAll(path, 0o700)
} else if err != nil {
return err
}
return nil
}
func readCSRFile(filename string) (*x509.CertificateRequest, error) {
bytes, err := os.ReadFile(filename)
if err != nil {