package ssl import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/hex" "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 { staging bool storageDir string store *ChallengeStore mu sync.Mutex clients map[string]*lego.Client } func NewIssuer(staging bool, storageDir string, store *ChallengeStore) *Issuer { return &Issuer{ staging: staging, storageDir: storageDir, store: store, clients: make(map[string]*lego.Client), } } func (i *Issuer) Obtain(ctx context.Context, email, domain, webroot string) (*certificate.Resource, error) { email = strings.TrimSpace(strings.ToLower(email)) if email == "" { return nil, errors.New("ssl email is not configured") } client, err := i.getClient(email) 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(email string) (*lego.Client, error) { i.mu.Lock() defer i.mu.Unlock() if client, ok := i.clients[email]; ok { return client, nil } user, err := i.loadOrCreateUser(email) 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.clients[email] = client return client, nil } func (i *Issuer) accountPath(email string) string { sum := sha256.Sum256([]byte(strings.ToLower(email))) id := hex.EncodeToString(sum[:8]) return filepath.Join(i.storageDir, "acme", id, "account.pem") } func (i *Issuer) loadOrCreateUser(email string) (*ACMEUser, error) { user := &ACMEUser{Email: email} path := i.accountPath(email) if data, err := os.ReadFile(path); 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(path), 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(user.Email), 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 }