diff --git a/openflare_server/service/lego_client.go b/openflare_server/service/lego_client.go index 1347b022..93f1ae91 100644 --- a/openflare_server/service/lego_client.go +++ b/openflare_server/service/lego_client.go @@ -1,222 +1,13 @@ package service import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" "fmt" "strings" - "time" "openflare/model" - - "github.com/go-acme/lego/v4/acme" - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/providers/dns/cloudflare" - "github.com/go-acme/lego/v4/registration" + "openflare/utils/acme" ) -type AcmeUser struct { - Email string - Registration *registration.Resource - key crypto.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 -} - -func parsePrivateKey(pemData string) (crypto.PrivateKey, error) { - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, errors.New("failed to parse PEM block containing the key") - } - - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - return nil, errors.New("failed to parse private key") -} - -func encodePrivateKey(key crypto.PrivateKey) (string, error) { - var pemBlock *pem.Block - switch k := key.(type) { - case *rsa.PrivateKey: - pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} - case *ecdsa.PrivateKey: - b, err := x509.MarshalECPrivateKey(k) - if err != nil { - return "", err - } - pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} - default: - return "", errors.New("unsupported key type") - } - return string(pem.EncodeToMemory(pemBlock)), nil -} - -func GetOrCreateLegoClient(account *model.AcmeAccount, keyAlgorithm string) (*lego.Client, *AcmeUser, error) { - var privateKey crypto.PrivateKey - var err error - - if account.PrivateKey == "" { - privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, err - } - pemStr, err := encodePrivateKey(privateKey) - if err != nil { - return nil, nil, err - } - account.PrivateKey = pemStr - // Don't save it to DB yet, wait for successful registration. - } else { - privateKey, err = parsePrivateKey(account.PrivateKey) - if err != nil { - return nil, nil, err - } - } - - user := &AcmeUser{ - Email: account.Email, - key: privateKey, - } - - if account.URL != "" { - user.Registration = ®istration.Resource{ - Body: acme.Account{ - Status: "valid", - Contact: []string{"mailto:" + account.Email}, - }, - URI: account.URL, - } - } - - config := lego.NewConfig(user) - // Use Let's Encrypt production environment by default - config.CADirURL = lego.LEDirectoryProduction - - switch keyAlgorithm { - case "RSA2048": - config.Certificate.KeyType = certcrypto.RSA2048 - case "RSA4096": - config.Certificate.KeyType = certcrypto.RSA4096 - case "EC256": - config.Certificate.KeyType = certcrypto.EC256 - case "EC384": - config.Certificate.KeyType = certcrypto.EC384 - default: - config.Certificate.KeyType = certcrypto.RSA2048 - } - - client, err := lego.NewClient(config) - if err != nil { - return nil, nil, err - } - - if account.URL == "" { - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - return nil, nil, err - } - user.Registration = reg - account.URL = reg.URI - if account.ID == 0 { - err = model.DB.Create(account).Error - } else { - err = model.DB.Save(account).Error - } - if err != nil { - return nil, nil, err - } - } - - return client, user, nil -} - -func SetupDNSProvider(client *lego.Client, dnsAccount *model.DnsAccount, dns1, dns2 string, disableCNAME, skipDNS bool) error { - var provider challengeProvider - - switch dnsAccount.Type { - case "cloudflare": - var creds map[string]string - if err := json.Unmarshal([]byte(dnsAccount.Authorization), &creds); err != nil { - return fmt.Errorf("failed to parse cloudflare credentials: %v", err) - } - - config := cloudflare.NewDefaultConfig() - config.AuthToken = creds["api_token"] - - p, err := cloudflare.NewDNSProviderConfig(config) - if err != nil { - return err - } - provider = p - default: - return fmt.Errorf("unsupported DNS provider: %s", dnsAccount.Type) - } - - // We can use custom DNS servers to verify challenges if provided - var resolvers []string - if dns1 != "" { - resolvers = append(resolvers, dns1+":53") - } - if dns2 != "" { - resolvers = append(resolvers, dns2+":53") - } - - var opts []dns01.ChallengeOption - - if len(resolvers) > 0 { - opts = append(opts, dns01.AddRecursiveNameservers(resolvers)) - } - - if disableCNAME { - opts = append(opts, dns01.DisableCompletePropagationRequirement()) - } - - if skipDNS { - opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) { - // If we skip the local DNS check entirely, we might trigger Let's Encrypt to verify - // BEFORE Cloudflare's edge servers have actually synced the TXT record (which takes 5-15 seconds). - // So we add a safe 20-second artificial delay before forcing the true return. - time.Sleep(20 * time.Second) - return true, nil - })) - } - - return client.Challenge.SetDNS01Provider(provider, opts...) -} - -// challengeProvider interface helps to bypass the strict type definition of SetDNS01Provider -type challengeProvider interface { - Present(domain, token, keyAuth string) error - CleanUp(domain, token, keyAuth string) error -} - func ObtainSSL(cert *model.TLSCertificate) error { cert.ApplyStatus = "applying" model.DB.Save(cert) @@ -240,18 +31,6 @@ func ObtainSSL(cert *model.TLSCertificate) error { return err } - client, _, err := GetOrCreateLegoClient(acmeAccount, cert.KeyAlgorithm) - if err != nil { - updateCertError(cert, fmt.Sprintf("Failed to create ACME client: %v", err)) - return err - } - - err = SetupDNSProvider(client, dnsAccount, cert.DNS1, cert.DNS2, cert.DisableCNAME, cert.SkipDNS) - if err != nil { - updateCertError(cert, fmt.Sprintf("Failed to setup DNS provider: %v", err)) - return err - } - domains := []string{cert.PrimaryDomain} if cert.OtherDomains != "" { for _, d := range strings.Split(cert.OtherDomains, "\n") { @@ -262,32 +41,56 @@ func ObtainSSL(cert *model.TLSCertificate) error { } } - request := certificate.ObtainRequest{ - Domains: domains, - Bundle: true, + newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL( + acmeAccount.Email, + acmeAccount.PrivateKey, + acmeAccount.URL, + dnsAccount.Type, + dnsAccount.Authorization, + cert.DNS1, + cert.DNS2, + cert.DisableCNAME, + cert.SkipDNS, + cert.KeyAlgorithm, + domains, + ) + + // If new key or URL was generated, save them to the DB + if (newPrivateKeyPEM != "" && acmeAccount.PrivateKey != newPrivateKeyPEM) || (newAccountURL != "" && acmeAccount.URL != newAccountURL) { + if newPrivateKeyPEM != "" { + acmeAccount.PrivateKey = newPrivateKeyPEM + } + if newAccountURL != "" { + acmeAccount.URL = newAccountURL + } + if acmeAccount.ID == 0 { + if dbErr := model.DB.Create(acmeAccount).Error; dbErr != nil { + updateCertError(cert, fmt.Sprintf("Failed to create ACME account: %v", dbErr)) + return dbErr + } + } else { + if dbErr := model.DB.Save(acmeAccount).Error; dbErr != nil { + updateCertError(cert, fmt.Sprintf("Failed to save ACME account: %v", dbErr)) + return dbErr + } + } + // Self-heal the cert + cert.AcmeAccountID = acmeAccount.ID + model.DB.Save(cert) } - certificates, err := client.Certificate.Obtain(request) if err != nil { - updateCertError(cert, fmt.Sprintf("Failed to obtain certificate: %v", err)) + updateCertError(cert, err.Error()) return err } - cert.CertPEM = string(certificates.Certificate) - cert.KeyPEM = string(certificates.PrivateKey) - - // Parse validity dates - certBlock, _ := pem.Decode(certificates.Certificate) - if certBlock != nil { - parsedCert, err := x509.ParseCertificate(certBlock.Bytes) - if err == nil { - cert.NotBefore = parsedCert.NotBefore - cert.NotAfter = parsedCert.NotAfter - } - } - + cert.CertPEM = result.CertPEM + cert.KeyPEM = result.KeyPEM + cert.NotBefore = result.NotBefore + cert.NotAfter = result.NotAfter cert.ApplyStatus = "ready" cert.ApplyMessage = "" + return model.DB.Save(cert).Error } diff --git a/openflare_server/utils/acme/client.go b/openflare_server/utils/acme/client.go new file mode 100644 index 00000000..736dafa3 --- /dev/null +++ b/openflare_server/utils/acme/client.go @@ -0,0 +1,256 @@ +package acme + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "time" + + "github.com/go-acme/lego/v4/acme" + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/go-acme/lego/v4/registration" +) + +type AcmeUser struct { + Email string + Registration *registration.Resource + key crypto.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 CertificateResult struct { + CertPEM string + KeyPEM string + NotBefore time.Time + NotAfter time.Time +} + +func parsePrivateKey(pemData string) (crypto.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("failed to parse PEM block containing the key") + } + + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + return key, nil + } + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + return nil, errors.New("failed to parse private key") +} + +func encodePrivateKey(key crypto.PrivateKey) (string, error) { + var pemBlock *pem.Block + switch k := key.(type) { + case *rsa.PrivateKey: + pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + return "", err + } + pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return "", errors.New("unsupported key type") + } + return string(pem.EncodeToMemory(pemBlock)), nil +} + +func GetOrCreateLegoClient(acmeEmail, privateKeyPEM, accountURL string, keyAlgorithm string) (*lego.Client, *AcmeUser, string, string, error) { + var privateKey crypto.PrivateKey + var err error + var newPrivateKeyPEM string + var newAccountURL string + + if privateKeyPEM == "" { + privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, "", "", err + } + pemStr, err := encodePrivateKey(privateKey) + if err != nil { + return nil, nil, "", "", err + } + newPrivateKeyPEM = pemStr + } else { + privateKey, err = parsePrivateKey(privateKeyPEM) + if err != nil { + return nil, nil, "", "", err + } + } + + user := &AcmeUser{ + Email: acmeEmail, + key: privateKey, + } + + if accountURL != "" { + user.Registration = ®istration.Resource{ + Body: acme.Account{ + Status: "valid", + Contact: []string{"mailto:" + acmeEmail}, + }, + URI: accountURL, + } + } + + config := lego.NewConfig(user) + // Use Let's Encrypt production environment by default + config.CADirURL = lego.LEDirectoryProduction + + switch keyAlgorithm { + case "RSA2048": + config.Certificate.KeyType = certcrypto.RSA2048 + case "RSA4096": + config.Certificate.KeyType = certcrypto.RSA4096 + case "EC256": + config.Certificate.KeyType = certcrypto.EC256 + case "EC384": + config.Certificate.KeyType = certcrypto.EC384 + default: + config.Certificate.KeyType = certcrypto.RSA2048 + } + + client, err := lego.NewClient(config) + if err != nil { + return nil, nil, "", "", err + } + + if accountURL == "" { + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + if err != nil { + return nil, nil, "", "", err + } + user.Registration = reg + newAccountURL = reg.URI + } + + return client, user, newPrivateKeyPEM, newAccountURL, nil +} + +func SetupDNSProvider(client *lego.Client, dnsType, dnsAuth string, dns1, dns2 string, disableCNAME, skipDNS bool) error { + var provider challengeProvider + + switch dnsType { + case "cloudflare": + var creds map[string]string + if err := json.Unmarshal([]byte(dnsAuth), &creds); err != nil { + return fmt.Errorf("failed to parse cloudflare credentials: %v", err) + } + + config := cloudflare.NewDefaultConfig() + config.AuthToken = creds["api_token"] + + p, err := cloudflare.NewDNSProviderConfig(config) + if err != nil { + return err + } + provider = p + default: + return fmt.Errorf("unsupported DNS provider: %s", dnsType) + } + + var resolvers []string + if dns1 != "" { + resolvers = append(resolvers, dns1+":53") + } + if dns2 != "" { + resolvers = append(resolvers, dns2+":53") + } + + var opts []dns01.ChallengeOption + + if len(resolvers) > 0 { + opts = append(opts, dns01.AddRecursiveNameservers(resolvers)) + } + + if disableCNAME { + opts = append(opts, dns01.DisableCompletePropagationRequirement()) + } + + if skipDNS { + opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) { + time.Sleep(20 * time.Second) + return true, nil + })) + } + + return client.Challenge.SetDNS01Provider(provider, opts...) +} + +type challengeProvider interface { + Present(domain, token, keyAuth string) error + CleanUp(domain, token, keyAuth string) error +} + +func ObtainSSL( + acmeEmail, acmePrivateKeyPEM, acmeURL string, + dnsType, dnsAuth string, + dns1, dns2 string, + disableCNAME, skipDNS bool, + keyAlgorithm string, + domains []string, +) (string, string, *CertificateResult, error) { + client, _, newPrivateKeyPEM, newAccountURL, err := GetOrCreateLegoClient(acmeEmail, acmePrivateKeyPEM, acmeURL, keyAlgorithm) + if err != nil { + return "", "", nil, fmt.Errorf("failed to create ACME client: %w", err) + } + + err = SetupDNSProvider(client, dnsType, dnsAuth, dns1, dns2, disableCNAME, skipDNS) + if err != nil { + return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to setup DNS provider: %w", err) + } + + request := certificate.ObtainRequest{ + Domains: domains, + Bundle: true, + } + + certificates, err := client.Certificate.Obtain(request) + if err != nil { + return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to obtain certificate: %w", err) + } + + result := &CertificateResult{ + CertPEM: string(certificates.Certificate), + KeyPEM: string(certificates.PrivateKey), + } + + // Parse validity dates + certBlock, _ := pem.Decode(certificates.Certificate) + if certBlock != nil { + parsedCert, err := x509.ParseCertificate(certBlock.Bytes) + if err == nil { + result.NotBefore = parsedCert.NotBefore + result.NotAfter = parsedCert.NotAfter + } + } + + return newAccountURL, newPrivateKeyPEM, result, nil +}