375 lines
9.4 KiB
Go
375 lines
9.4 KiB
Go
package cert_manager_wdd
|
||
|
||
import (
|
||
"agent-wdd/cloudflare"
|
||
"agent-wdd/log"
|
||
"context"
|
||
"crypto"
|
||
"crypto/ecdsa"
|
||
"crypto/elliptic"
|
||
"crypto/rand"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"encoding/pem"
|
||
|
||
"golang.org/x/crypto/acme"
|
||
)
|
||
|
||
// ACMEClient ACME协议客户端
|
||
type ACMEClient struct {
|
||
client *acme.Client
|
||
account *acme.Account
|
||
accountKey crypto.Signer
|
||
directory string
|
||
email string
|
||
cfAPI *cloudflare.CloudflareClient
|
||
}
|
||
|
||
// NewACMEClient 创建新的ACME客户端
|
||
//
|
||
// 参数:
|
||
// - directory: ACME服务器目录URL
|
||
// - email: 注册账户的邮箱
|
||
// - cfAPI: Cloudflare API客户端
|
||
//
|
||
// 返回:
|
||
// - *ACMEClient: ACME客户端
|
||
// - error: 错误信息
|
||
func NewACMEClient(directory, email string, cfAPI *cloudflare.CloudflareClient) (*ACMEClient, error) {
|
||
log.Debug("创建ACME客户端,目录: %s, 邮箱: %s", directory, email)
|
||
|
||
// 创建客户端
|
||
client := &acme.Client{
|
||
DirectoryURL: directory,
|
||
}
|
||
|
||
// 生成账户密钥
|
||
accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||
if err != nil {
|
||
log.Error("生成ACME账户密钥失败: %v", err)
|
||
return nil, fmt.Errorf("生成ACME账户密钥失败: %w", err)
|
||
}
|
||
|
||
acmeClient := &ACMEClient{
|
||
client: client,
|
||
accountKey: accountKey,
|
||
directory: directory,
|
||
email: email,
|
||
cfAPI: cfAPI,
|
||
}
|
||
|
||
// 注册账户
|
||
if err := acmeClient.registerAccount(); err != nil {
|
||
log.Error("ACME账户注册失败: %v", err)
|
||
return nil, fmt.Errorf("ACME账户注册失败: %w", err)
|
||
}
|
||
|
||
return acmeClient, nil
|
||
}
|
||
|
||
// registerAccount 注册ACME账户
|
||
func (ac *ACMEClient) registerAccount() error {
|
||
log.Debug("注册ACME账户")
|
||
|
||
// 创建上下文
|
||
ctx := context.Background()
|
||
|
||
// 设置账户密钥
|
||
ac.client.Key = ac.accountKey
|
||
|
||
// 注册新账户
|
||
account := &acme.Account{
|
||
Contact: []string{"mailto:" + ac.email},
|
||
}
|
||
account, err := ac.client.Register(ctx, account, acme.AcceptTOS)
|
||
if err != nil {
|
||
// 错误处理,特别处理已存在账户的情况
|
||
if err.(*acme.Error).StatusCode == 409 {
|
||
log.Info("ACME账户已存在,已重新获取")
|
||
// 获取现有账户
|
||
account, err = ac.client.GetReg(ctx, "")
|
||
if err != nil {
|
||
return fmt.Errorf("获取现有ACME账户失败: %w", err)
|
||
}
|
||
} else {
|
||
return fmt.Errorf("注册ACME账户失败: %w", err)
|
||
}
|
||
}
|
||
|
||
ac.account = account
|
||
log.Info("ACME账户注册/获取成功")
|
||
return nil
|
||
}
|
||
|
||
// ObtainCertificate 获取证书
|
||
//
|
||
// 参数:
|
||
// - domain: 需要申请证书的域名
|
||
// - privateKey: 证书私钥
|
||
// - certDir: 证书保存目录
|
||
//
|
||
// 返回:
|
||
// - *CertInfo: 证书信息
|
||
// - error: 错误信息
|
||
func (ac *ACMEClient) ObtainCertificate(domain string, privateKey crypto.Signer, certDir string) (*CertInfo, error) {
|
||
log.Info("开始获取域名 %s 的证书", domain)
|
||
|
||
// 创建上下文
|
||
ctx := context.Background()
|
||
|
||
// 创建新订单
|
||
order, err := ac.client.AuthorizeOrder(ctx, []acme.AuthzID{
|
||
{Type: "dns", Value: domain},
|
||
})
|
||
if err != nil {
|
||
log.Error("创建ACME订单失败: %v", err)
|
||
return nil, fmt.Errorf("创建ACME订单失败: %w", err)
|
||
}
|
||
|
||
// 处理授权
|
||
for _, authzURL := range order.AuthzURLs {
|
||
authz, err := ac.client.GetAuthorization(ctx, authzURL)
|
||
if err != nil {
|
||
log.Error("获取授权信息失败: %v", err)
|
||
return nil, fmt.Errorf("获取授权信息失败: %w", err)
|
||
}
|
||
|
||
// 查找DNS挑战
|
||
var challenge *acme.Challenge
|
||
for _, c := range authz.Challenges {
|
||
if c.Type == "dns-01" {
|
||
challenge = c
|
||
break
|
||
}
|
||
}
|
||
|
||
if challenge == nil {
|
||
log.Error("未找到DNS-01挑战")
|
||
return nil, fmt.Errorf("未找到DNS-01挑战")
|
||
}
|
||
|
||
// 获取TXT记录值
|
||
txtValue, err := ac.client.DNS01ChallengeRecord(challenge.Token)
|
||
if err != nil {
|
||
log.Error("获取DNS-01挑战记录失败: %v", err)
|
||
return nil, fmt.Errorf("获取DNS-01挑战记录失败: %w", err)
|
||
}
|
||
|
||
// 使用Cloudflare API设置DNS TXT记录
|
||
if err := ac.setDNSChallengeTXT(domain, txtValue); err != nil {
|
||
log.Error("设置DNS TXT记录失败: %v", err)
|
||
return nil, fmt.Errorf("设置DNS TXT记录失败: %w", err)
|
||
}
|
||
|
||
// 等待DNS传播
|
||
log.Info("等待DNS记录传播...")
|
||
time.Sleep(30 * time.Second)
|
||
|
||
// 通知ACME服务器完成挑战
|
||
if _, err := ac.client.Accept(ctx, challenge); err != nil {
|
||
log.Error("接受挑战失败: %v", err)
|
||
return nil, fmt.Errorf("接受挑战失败: %w", err)
|
||
}
|
||
|
||
// 等待授权完成
|
||
_, err = ac.client.WaitAuthorization(ctx, authzURL)
|
||
if err != nil {
|
||
log.Error("等待授权完成失败: %v", err)
|
||
return nil, fmt.Errorf("等待授权完成失败: %w", err)
|
||
}
|
||
|
||
// 清理DNS记录
|
||
if err := ac.cleanupDNSChallenge(domain); err != nil {
|
||
log.Warning("清理DNS TXT记录失败: %v", err)
|
||
// 不中断流程,继续申请证书
|
||
}
|
||
}
|
||
|
||
// 创建CSR
|
||
csr, err := createCSR(privateKey, domain)
|
||
if err != nil {
|
||
log.Error("创建CSR失败: %v", err)
|
||
return nil, fmt.Errorf("创建CSR失败: %w", err)
|
||
}
|
||
|
||
// 完成订单
|
||
certDERs, _, err := ac.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
||
if err != nil {
|
||
log.Error("完成订单失败: %v", err)
|
||
return nil, fmt.Errorf("完成订单失败: %w", err)
|
||
}
|
||
|
||
// 处理证书
|
||
var pemData []byte
|
||
for _, der := range certDERs {
|
||
b := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||
pemData = append(pemData, b...)
|
||
}
|
||
|
||
// 保存证书
|
||
certPath := filepath.Join(certDir, "cert.pem")
|
||
if err := os.WriteFile(certPath, pemData, 0644); err != nil {
|
||
log.Error("保存证书失败: %v", err)
|
||
return nil, fmt.Errorf("保存证书失败: %w", err)
|
||
}
|
||
|
||
// 解析证书以获取详细信息
|
||
cert, err := parseCertificate(certPath)
|
||
if err != nil {
|
||
log.Error("解析保存的证书失败: %v", err)
|
||
return nil, fmt.Errorf("解析保存的证书失败: %w", err)
|
||
}
|
||
|
||
// 构建证书信息
|
||
keyPath := filepath.Join(certDir, "private.key")
|
||
certInfo := &CertInfo{
|
||
Domain: domain,
|
||
RegisteredAt: cert.NotBefore,
|
||
ExpiresAt: cert.NotAfter,
|
||
CertPath: certPath,
|
||
KeyPath: keyPath,
|
||
CAName: getCertCAName(cert),
|
||
NeedsRenewal: false,
|
||
WildcardCert: strings.HasPrefix(domain, "*."),
|
||
DaysRemaining: int(cert.NotAfter.Sub(time.Now()).Hours() / 24),
|
||
}
|
||
|
||
log.Info("证书获取成功: %s", domain)
|
||
return certInfo, nil
|
||
}
|
||
|
||
// setDNSChallengeTXT 设置DNS TXT记录用于ACME验证
|
||
func (ac *ACMEClient) setDNSChallengeTXT(domain, txtValue string) error {
|
||
log.Debug("设置DNS TXT记录: %s", domain)
|
||
|
||
// 处理域名,提取主域名和记录名
|
||
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
|
||
if strings.HasPrefix(domain, "*.") {
|
||
// 通配符域名处理
|
||
recordName = fmt.Sprintf("_acme-challenge.%s", domain[2:])
|
||
}
|
||
|
||
// 获取域名的zone信息
|
||
zones, err := ac.cfAPI.ListZones(nil)
|
||
if err != nil {
|
||
return fmt.Errorf("获取Cloudflare zones失败: %w", err)
|
||
}
|
||
|
||
var zoneID string
|
||
for _, zone := range zones {
|
||
if strings.HasSuffix(domain, zone.Name) || strings.HasSuffix(domain[2:], zone.Name) {
|
||
zoneID = zone.ID
|
||
break
|
||
}
|
||
}
|
||
|
||
if zoneID == "" {
|
||
return fmt.Errorf("未找到域名 %s 的Cloudflare Zone", domain)
|
||
}
|
||
|
||
// 创建TXT记录
|
||
record := cloudflare.DNSRecord{
|
||
Type: "TXT",
|
||
Name: recordName,
|
||
Content: txtValue,
|
||
TTL: 120,
|
||
}
|
||
|
||
_, err = ac.cfAPI.CreateDNSRecord(zoneID, record)
|
||
if err != nil {
|
||
return fmt.Errorf("创建DNS TXT记录失败: %w", err)
|
||
}
|
||
|
||
log.Info("DNS TXT记录已创建: %s", recordName)
|
||
return nil
|
||
}
|
||
|
||
// cleanupDNSChallenge 清理DNS验证记录
|
||
func (ac *ACMEClient) cleanupDNSChallenge(domain string) error {
|
||
log.Debug("清理DNS TXT记录: %s", domain)
|
||
|
||
// 处理域名,提取主域名和记录名
|
||
recordName := fmt.Sprintf("_acme-challenge.%s", domain)
|
||
if strings.HasPrefix(domain, "*.") {
|
||
// 通配符域名处理
|
||
recordName = fmt.Sprintf("_acme-challenge.%s", domain[2:])
|
||
}
|
||
|
||
// 获取域名的zone信息
|
||
zones, err := ac.cfAPI.ListZones(nil)
|
||
if err != nil {
|
||
return fmt.Errorf("获取Cloudflare zones失败: %w", err)
|
||
}
|
||
|
||
var zoneID string
|
||
for _, zone := range zones {
|
||
if strings.HasSuffix(domain, zone.Name) || strings.HasSuffix(domain[2:], zone.Name) {
|
||
zoneID = zone.ID
|
||
break
|
||
}
|
||
}
|
||
|
||
if zoneID == "" {
|
||
return fmt.Errorf("未找到域名 %s 的Cloudflare Zone", domain)
|
||
}
|
||
|
||
// 查找TXT记录
|
||
filter := &cloudflare.DNSRecordFilter{
|
||
Type: "TXT",
|
||
Name: recordName,
|
||
}
|
||
|
||
records, err := ac.cfAPI.ListDNSRecords(zoneID, filter)
|
||
if err != nil {
|
||
return fmt.Errorf("查找DNS TXT记录失败: %w", err)
|
||
}
|
||
|
||
// 删除匹配的记录
|
||
for _, record := range records {
|
||
if record.Name == recordName && record.Type == "TXT" {
|
||
_, err = ac.cfAPI.DeleteDNSRecord(zoneID, record.ID)
|
||
if err != nil {
|
||
return fmt.Errorf("删除DNS TXT记录失败: %w", err)
|
||
}
|
||
log.Info("DNS TXT记录已删除: %s", recordName)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// createCSR 创建证书签名请求
|
||
func createCSR(privateKey crypto.Signer, domain string) ([]byte, error) {
|
||
template := &x509.CertificateRequest{
|
||
Subject: pkix.Name{
|
||
CommonName: domain,
|
||
},
|
||
}
|
||
|
||
// 处理通配符域名
|
||
if strings.HasPrefix(domain, "*.") {
|
||
template.DNSNames = []string{domain, domain[2:]}
|
||
} else {
|
||
template.DNSNames = []string{domain}
|
||
}
|
||
|
||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return csrDER, nil
|
||
}
|
||
|
||
// pemEncode 将DER格式数据编码为PEM格式
|
||
func pemEncode(typ string, der []byte) []byte {
|
||
block := &pem.Block{Type: typ, Bytes: der}
|
||
return pem.EncodeToMemory(block)
|
||
}
|