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/challenge/http01" "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 challengeProvider struct { webroot string store *ChallengeStore httpAddr string httpSrv *http01.ProviderServer } func (p *challengeProvider) 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 } if err := os.WriteFile(filepath.Join(dir, token), []byte(keyAuth), 0o644); err != nil { return err } if p.httpSrv != nil { return p.httpSrv.Present(domain, token, keyAuth) } return nil } func (p *challengeProvider) CleanUp(domain, token, keyAuth string) error { p.store.Delete(token) _ = os.Remove(filepath.Join(p.webroot, ".well-known", "acme-challenge", token)) if p.httpSrv != nil { return p.httpSrv.CleanUp(domain, token, keyAuth) } return nil } type Issuer struct { staging bool storageDir string acmeHTTPAddr string store *ChallengeStore mu sync.Mutex clients map[string]*lego.Client } func NewIssuer(staging bool, storageDir, acmeHTTPAddr string, store *ChallengeStore) *Issuer { return &Issuer{ staging: staging, storageDir: storageDir, acmeHTTPAddr: acmeHTTPAddr, 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 := &challengeProvider{webroot: webroot, store: i.store} if i.acmeHTTPAddr != "" { port := "80" if strings.Contains(i.acmeHTTPAddr, ":") { port = strings.TrimPrefix(i.acmeHTTPAddr, ":") } provider.httpSrv = http01.NewProviderServer("", port) } 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%s", err, tlsErrorHint(err)) } if err := i.saveToDisk(domain, res); err != nil { log.Printf("ssl: save to disk: %v", err) } return res, nil } func tlsErrorHint(err error) string { msg := err.Error() if strings.Contains(msg, "error:tls") || strings.Contains(msg, "remote error: tls") { return " — домен перенаправляет HTTP→HTTPS; нужен nginx location для /.well-known/acme-challenge/ без редиректа" } return "" } 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 }