Files

259 lines
6.4 KiB
Go

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
}