package ssl import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/pem" "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" ) type ACMEUser struct { Email string Registration *registration.Resource key *ecdsa.PrivateKey } func (u *ACMEUser) GetEmail() string { return u.Email } func (u *ACMEUser) GetRegistration() *registration.Resource { return u.Registration } func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey { return u.key } type webrootProvider struct { webroot string store *ChallengeStore } func (p *webrootProvider) Present(domain, token, keyAuth string) error { p.store.Set(token, keyAuth) dir := filepath.Join(p.webroot, ".well-known", "acme-challenge") if err := os.MkdirAll(dir, 0o755); err != nil { return err } return os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644) } func (p *webrootProvider) CleanUp(domain, token, keyAuth string) error { p.store.Delete(token) _ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", token)) return nil } type Issuer struct { email string staging bool storageDir string store *ChallengeStore mu sync.Mutex client *lego.Client } func NewIssuer(email string, staging bool, storageDir string, store *ChallengeStore) *Issuer { return &Issuer{ email: email, staging: staging, storageDir: storageDir, store: store, } } func (i *Issuer) Obtain(ctx context.Context, domain, webroot string) (*certificate.Resource, error) { if i.email == "" { return nil, errors.New("ACME_EMAIL is not configured") } client, err := i.getClient() if err != nil { return nil, err } provider := &webrootProvider{webroot: webroot, store: i.store} if err := client.Challenge.SetHTTP01Provider(provider); err != nil { return nil, err } request := certificate.ObtainRequest{ Domains: []string{domain}, Bundle: true, } res, err := client.Certificate.Obtain(request) if err != nil { return nil, fmt.Errorf("acme obtain: %w", err) } if err := i.saveToDisk(domain, res); err != nil { log.Printf("ssl: save to disk: %v", err) } return res, nil } func (i *Issuer) getClient() (*lego.Client, error) { i.mu.Lock() defer i.mu.Unlock() if i.client != nil { return i.client, nil } user, err := i.loadOrCreateUser() if err != nil { return nil, err } config := lego.NewConfig(user) config.Certificate.KeyType = certcrypto.RSA2048 if i.staging { config.CADirURL = lego.LEDirectoryStaging } else { config.CADirURL = lego.LEDirectoryProduction } client, err := lego.NewClient(config) if err != nil { return nil, err } if user.Registration == nil { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { return nil, fmt.Errorf("acme register: %w", err) } user.Registration = reg if err := i.saveAccount(user); err != nil { log.Printf("ssl: save acme account: %v", err) } } i.client = client return client, nil } func (i *Issuer) accountPath() string { return filepath.Join(i.storageDir, "acme", "account.pem") } func (i *Issuer) loadOrCreateUser() (*ACMEUser, error) { user := &ACMEUser{Email: i.email} if data, err := os.ReadFile(i.accountPath()); err == nil { block, _ := pem.Decode(data) if block != nil { key, err := x509.ParseECPrivateKey(block.Bytes) if err == nil { user.key = key return user, nil } } } key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } user.key = key if err := os.MkdirAll(filepath.Dir(i.accountPath()), 0o700); err != nil { return nil, err } if err := i.saveAccount(user); err != nil { return nil, err } return user, nil } func (i *Issuer) saveAccount(user *ACMEUser) error { der, err := x509.MarshalECPrivateKey(user.key) if err != nil { return err } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) return os.WriteFile(i.accountPath(), pemBytes, 0o600) } func (i *Issuer) saveToDisk(domain string, res *certificate.Resource) error { dir := filepath.Join(i.storageDir, "live", sanitizeDomain(domain)) if err := os.MkdirAll(dir, 0o700); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "fullchain.pem"), res.Certificate, 0o644); err != nil { return err } return os.WriteFile(filepath.Join(dir, "privkey.pem"), res.PrivateKey, 0o600) } func (i *Issuer) Store() *ChallengeStore { return i.store } func sanitizeDomain(domain string) string { return strings.ReplaceAll(domain, "..", "") } func CertPaths(storageDir, domain string) (certPath, keyPath string) { dir := filepath.Join(storageDir, "live", sanitizeDomain(domain)) return filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem") } func ParseCertExpiry(certPEM []byte) (time.Time, error) { block, _ := pem.Decode(certPEM) if block == nil { return time.Time{}, errors.New("invalid certificate pem") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return time.Time{}, err } return cert.NotAfter, nil }