diff --git a/.cursor/rules/agent-wdd.mdc b/.cursor/rules/agent-wdd.mdc new file mode 100644 index 0000000..bbf34cc --- /dev/null +++ b/.cursor/rules/agent-wdd.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: +alwaysApply: true +--- + +# 你是一个精通cobra的大师,熟知现代化CLI工具的开发,能够实现架构健壮的CLI程序 +- 能够设计出功能完善,健壮的交互式CLI + +# 项目代码风格要求 +- 函数的参数注释,返回注释,功能注释 +- 在代码编写过程中,使用 [CallerLog.go](mdc:agent-wdd/log/CallerLog.go) 多打印日志 +- 代码中也有详细的流程说明,功能说明 + + + diff --git a/.cursor/rules/cert-manager-wdd.mdc b/.cursor/rules/cert-manager-wdd.mdc new file mode 100644 index 0000000..59801a9 --- /dev/null +++ b/.cursor/rules/cert-manager-wdd.mdc @@ -0,0 +1,35 @@ +--- +description: 关于WDD实现的证书管理工具的设计概要 +globs: +alwaysApply: false +--- + + +# 模块目的 +- 设置一个SSL证书申请 有效期管理的简单工具 + +### 参考项目 +- 是 https://github.com/cert-manager/cert-manager 的超级简化版本 +- 是 https://github.com/acmesh-official/acme.sh 的平行替代版本 + +## 申请证书 +- 输入一个域名,可以支持Cloudflare DNS验证的方式申请域名 +- 申请域名的格式默认为ec-256 +- CA服务器 不要违反其滥用策略 保持合适的等待时间 + - ZeroSSL + - Let's Encrypt +- 证书目录 + - 设置默认的证书保存目录,按照域名的形式保存证书 + - 通配域名 如 *.qq.com的文件夹名称用 x.qq.com表示 + +## 证书查看 +- 能够查看所有申请的证书信息 +- domain名称 +- 注册时间 +- 到期时间 +- 是否需要更新(有效期不足30天) + +## 证书更新 +- 传入domain名称 +- 查询该证书的详细信息 +- 重新为该domain申请新的证书 diff --git a/.cursor/rules/cloudflare.mdc b/.cursor/rules/cloudflare.mdc new file mode 100644 index 0000000..0fe8cc3 --- /dev/null +++ b/.cursor/rules/cloudflare.mdc @@ -0,0 +1,144 @@ +--- +description: cloudflare相关的说明 +globs: +alwaysApply: false +--- + +# Cloudflare 域管理及DNS管理功能 +- cloudflare的统一返回结构体为 +{ + "result": any, + "result_info": { + "page": 1, + "per_page": 20, + "total_pages": 1, + "count": 1, + "total_count": 1 + }, + "success": true, + "errors": [ + { + "code": 6003, + "message": "Invalid request headers", + "error_chain": [ + { + "code": 6111, + "message": "Invalid format for Authorization header" + } + ] + } + ], + "messages": [] +} + + +## domain管理功能 + +### domain的查询功能 +- 返回结构体如下 +{ + "result": [ + { + "id": "511894a4f1357feb905e974e16241ebb", + "name": "107421.xyz", + "status": "active", + "paused": false, + "type": "full", + "development_mode": 0, + "name_servers": [ + "ali.ns.cloudflare.com", + "walt.ns.cloudflare.com" + ], + "original_name_servers": [ + "ns1.dnsowl.com", + "ns2.dnsowl.com", + "ns3.dnsowl.com" + ], + "original_registrar": null, + "original_dnshost": null, + "modified_on": "2021-11-03T01:47:42.506130Z", + "created_on": "2021-11-03T01:33:07.447056Z", + "activated_on": "2021-11-03T01:47:42.506130Z", + "meta": { + "step": 2, + "custom_certificate_quota": 0, + "page_rule_quota": 3, + "phishing_detected": false + }, + "owner": { + "id": null, + "type": "user", + "email": null + }, + "account": { + "id": "dfaadeb83406ef5ad35da02617af9191", + "name": "Zeaslity@gmail.com's Account" + }, + "tenant": { + "id": null, + "name": null + }, + "tenant_unit": { + "id": null + }, + "permissions": [ + "#zone:read", + "#zone_settings:read", + "#dns_records:edit", + "#dns_records:read" + ], + "plan": { + "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "name": "Free Website", + "price": 0, + "currency": "USD", + "frequency": "", + "is_subscribed": false, + "can_subscribe": false, + "legacy_id": "free", + "legacy_discount": false, + "externally_managed": false + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "total_pages": 1, + "count": 1, + "total_count": 1 + }, + "success": true, + "errors": [], + "messages": [] +} + + +## DNS管理功能实现 +- 需要实现特定domain的DNS的管理 +- 参考文档 https://developers.cloudflare.com/api/resources/dns/subresources/records/ + +### 查询全部DNS记录 +- 单条DNS记录的结构体如下 { + "id": "b940757dfd94e8e2941e2ec1b2ee0515", + "name": "mail.107421.xyz", + "type": "MX", + "content": "mx2.yuanyoupush.com", + "priority": 20, + "proxiable": false, + "proxied": false, + "ttl": 1, + "settings": {}, + "meta": {}, + "comment": "全自动邮箱", + "tags": [], + "created_on": "2025-03-15T05:56:37.296599Z", + "modified_on": "2025-03-15T05:57:08.180765Z", + "comment_modified_on": "2025-03-15T05:56:37.296599Z" + }, + +### 更新某个DNS记录 + +### 删除某个DNS记录 + +### 新增一个DNS记录 diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..f042fd2 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1743062665598 + + + + + + + + + + + + true + + \ No newline at end of file diff --git a/agent-wdd/cert_manager_wdd/AcmeClient.go b/agent-wdd/cert_manager_wdd/AcmeClient.go new file mode 100644 index 0000000..875c0d6 --- /dev/null +++ b/agent-wdd/cert_manager_wdd/AcmeClient.go @@ -0,0 +1,374 @@ +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) +} diff --git a/agent-wdd/cert_manager_wdd/CertMan.go b/agent-wdd/cert_manager_wdd/CertMan.go new file mode 100644 index 0000000..40cba0d --- /dev/null +++ b/agent-wdd/cert_manager_wdd/CertMan.go @@ -0,0 +1,321 @@ +package cert_manager_wdd + +import ( + "agent-wdd/cloudflare" + "agent-wdd/log" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // DefaultCertDir 默认证书保存目录 + DefaultCertDir = "certs" + // DefaultDaysBeforeRenewal 默认更新阈值天数 + DefaultDaysBeforeRenewal = 30 + // DefaultKeyType 默认密钥类型 + DefaultKeyType = "ec-256" +) + +// CACertInfo CA证书服务器信息 +type CACertInfo struct { + Name string // 证书颁发机构名称 + Directory string // ACME目录URL +} + +// 预定义的CA服务器 +var ( + ZeroSSLCA = CACertInfo{ + Name: "ZeroSSL", + Directory: "https://acme.zerossl.com/v2/DV90", + } + + LetsEncryptCA = CACertInfo{ + Name: "Let's Encrypt", + Directory: "https://acme-v02.api.letsencrypt.org/directory", + } +) + +// CertInfo 存储证书信息 +type CertInfo struct { + Domain string // 证书域名 + RegisteredAt time.Time // 注册时间 + ExpiresAt time.Time // 过期时间 + CertPath string // 证书路径 + KeyPath string // 密钥路径 + CAName string // CA名称 + NeedsRenewal bool // 是否需要更新 + WildcardCert bool // 是否为通配符证书 + DaysRemaining int // 剩余有效天数 +} + +// CertManager 证书管理类 +type CertManager struct { + CertDir string // 证书保存目录 + CloudflareAPI *cloudflare.CloudflareClient // Cloudflare API客户端 + CAServer CACertInfo // 使用的CA服务器 + DaysBeforeRenewal int // 证书更新阈值天数 + EmailAddr string // 申请证书使用的邮箱 +} + +// NewCertManager 创建一个新的证书管理器 +// +// 参数: +// - certDir: 证书保存目录,若为空则使用默认目录 +// - cfToken: Cloudflare API令牌 +// - emailAddr: 申请证书使用的邮箱 +// +// 返回: +// - *CertManager: 初始化的证书管理器 +func NewCertManager(certDir string, cfToken string, emailAddr string) *CertManager { + if certDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Warning("无法获取用户主目录: %v,使用当前目录", err) + homeDir = "." + } + certDir = filepath.Join(homeDir, DefaultCertDir) + } + + // 确保证书目录存在 + if err := os.MkdirAll(certDir, 0755); err != nil { + log.Error("创建证书目录失败: %v", err) + } + + return &CertManager{ + CertDir: certDir, + CloudflareAPI: cloudflare.GetInstance(cfToken), + CAServer: LetsEncryptCA, // 默认使用Let's Encrypt + DaysBeforeRenewal: DefaultDaysBeforeRenewal, + EmailAddr: emailAddr, + } +} + +// SetCAServer 设置CA服务器 +// +// 参数: +// - caServer: CA服务器信息 +func (cm *CertManager) SetCAServer(caServer CACertInfo) { + cm.CAServer = caServer + log.Info("已将CA服务器设置为 %s", caServer.Name) +} + +// ApplyCertificate 申请证书 +// +// 参数: +// - domain: 需要申请证书的域名 +// +// 返回: +// - *CertInfo: 证书信息 +// - error: 错误信息 +func (cm *CertManager) ApplyCertificate(domain string) (*CertInfo, error) { + log.Info("开始为域名 %s 申请证书", domain) + + // 处理通配符域名文件夹命名 + folderName := domain + if strings.HasPrefix(domain, "*.") { + folderName = "x" + domain[1:] + log.Debug("通配符域名文件夹命名为: %s", folderName) + } + + // 创建域名对应的证书目录 + certFolder := filepath.Join(cm.CertDir, folderName) + if err := os.MkdirAll(certFolder, 0755); err != nil { + log.Error("创建域名证书目录失败: %v", err) + return nil, fmt.Errorf("创建域名证书目录失败: %w", err) + } + + // 生成私钥 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Error("生成私钥失败: %v", err) + return nil, fmt.Errorf("生成私钥失败: %w", err) + } + + keyPath := filepath.Join(certFolder, "private.key") + if err := savePrivateKey(privateKey, keyPath); err != nil { + log.Error("保存私钥失败: %v", err) + return nil, fmt.Errorf("保存私钥失败: %w", err) + } + + // 使用自定义的DNS验证方法 + dnsSolver := &DNSSolver{ + CloudflareAPI: cm.CloudflareAPI, + } + + // 使用自定义方法申请证书 + issuedCert, err := IssueCertificate(domain, cm.EmailAddr, cm.CAServer.Directory, privateKey, certFolder, dnsSolver) + if err != nil { + log.Error("证书申请失败: %v", err) + return nil, fmt.Errorf("证书申请失败: %w", err) + } + + log.Info("证书申请成功: %s", domain) + return issuedCert, nil +} + +// GetAllCerts 获取所有证书信息 +// +// 返回: +// - []CertInfo: 证书信息列表 +// - error: 错误信息 +func (cm *CertManager) GetAllCerts() ([]CertInfo, error) { + log.Info("获取所有证书信息") + var certs []CertInfo + + // 遍历证书目录 + err := filepath.Walk(cm.CertDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // 只处理目录 + if !info.IsDir() || path == cm.CertDir { + return nil + } + + // 尝试解析证书 + certPath := filepath.Join(path, "cert.pem") + keyPath := filepath.Join(path, "private.key") + + if !fileExists(certPath) || !fileExists(keyPath) { + log.Debug("目录 %s 中未找到完整的证书文件", path) + return nil + } + + cert, err := parseCertificate(certPath) + if err != nil { + log.Warning("解析证书 %s 失败: %v", certPath, err) + return nil + } + + // 获取域名 + folderName := filepath.Base(path) + domain := folderName + if strings.HasPrefix(folderName, "x") { + domain = "*" + domain[1:] + } + + // 计算剩余天数 + daysRemaining := int(cert.NotAfter.Sub(time.Now()).Hours() / 24) + needsRenewal := daysRemaining <= cm.DaysBeforeRenewal + + certInfo := CertInfo{ + Domain: domain, + RegisteredAt: cert.NotBefore, + ExpiresAt: cert.NotAfter, + CertPath: certPath, + KeyPath: keyPath, + CAName: getCertCAName(cert), + NeedsRenewal: needsRenewal, + WildcardCert: strings.HasPrefix(domain, "*."), + DaysRemaining: daysRemaining, + } + + certs = append(certs, certInfo) + return nil + }) + + if err != nil { + log.Error("遍历证书目录失败: %v", err) + return nil, fmt.Errorf("遍历证书目录失败: %w", err) + } + + log.Info("找到 %d 个证书", len(certs)) + return certs, nil +} + +// RenewCertificate 更新证书 +// +// 参数: +// - domain: 需要更新证书的域名 +// +// 返回: +// - *CertInfo: 更新后的证书信息 +// - error: 错误信息 +func (cm *CertManager) RenewCertificate(domain string) (*CertInfo, error) { + log.Info("开始更新域名 %s 的证书", domain) + + // 获取当前证书信息 + allCerts, err := cm.GetAllCerts() + if err != nil { + return nil, err + } + + var certToRenew *CertInfo + for _, cert := range allCerts { + if cert.Domain == domain { + certToRenew = &cert + break + } + } + + if certToRenew == nil { + log.Error("未找到域名 %s 的证书", domain) + return nil, fmt.Errorf("未找到域名 %s 的证书", domain) + } + + // 调用申请证书的方法重新申请 + return cm.ApplyCertificate(domain) +} + +// 工具函数 + +// savePrivateKey 保存私钥到文件 +func savePrivateKey(privateKey *ecdsa.PrivateKey, filePath string) error { + // 将私钥编码为PKCS8格式 + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return err + } + + // 将私钥编码为PEM格式 + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + // 写入文件 + return os.WriteFile(filePath, privateKeyPEM, 0600) +} + +// parseCertificate 解析证书文件 +func parseCertificate(certPath string) (*x509.Certificate, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("无法解码证书PEM数据") + } + + return x509.ParseCertificate(block.Bytes) +} + +// getCertCAName 获取证书颁发机构名称 +func getCertCAName(cert *x509.Certificate) string { + // 尝试从证书中提取颁发者信息 + if cert.Issuer.CommonName != "" { + if strings.Contains(cert.Issuer.CommonName, "Let's Encrypt") { + return "Let's Encrypt" + } else if strings.Contains(cert.Issuer.CommonName, "ZeroSSL") { + return "ZeroSSL" + } + } + return cert.Issuer.CommonName +} + +// fileExists 检查文件是否存在 +func fileExists(filePath string) bool { + _, err := os.Stat(filePath) + return err == nil +} diff --git a/agent-wdd/cert_manager_wdd/DNSSolver.go b/agent-wdd/cert_manager_wdd/DNSSolver.go new file mode 100644 index 0000000..2395aef --- /dev/null +++ b/agent-wdd/cert_manager_wdd/DNSSolver.go @@ -0,0 +1,187 @@ +package cert_manager_wdd + +import ( + "agent-wdd/cloudflare" + "agent-wdd/log" + "crypto/ecdsa" + "fmt" + "strings" + "time" +) + +// DNSSolver DNS验证器 +type DNSSolver struct { + CloudflareAPI *cloudflare.CloudflareClient // Cloudflare API客户端 +} + +// SetTXTRecord 设置DNS TXT记录 +// +// 参数: +// - domain: 域名 +// - token: 验证token +// - keyAuth: 验证密钥 +// +// 返回: +// - error: 错误信息 +func (ds *DNSSolver) SetTXTRecord(domain, token, keyAuth 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:]) + } + + // 计算记录值 + // 对于DNS-01验证,值是密钥授权的SHA-256哈希的Base64编码 + recordValue := keyAuth + + // 获取域名的zone信息 + zones, err := ds.CloudflareAPI.ListZones(nil) + if err != nil { + return fmt.Errorf("获取Cloudflare zones失败: %w", err) + } + + var zoneID string + zoneName := "" + for _, zone := range zones { + // 对于常规域名,检查域名是否以zone名称结尾 + // 对于通配符域名,检查去掉*. 前缀的域名是否以zone名称结尾 + domainToCheck := domain + if strings.HasPrefix(domain, "*.") { + domainToCheck = domain[2:] + } + + if strings.HasSuffix(domainToCheck, zone.Name) && (zoneName == "" || len(zone.Name) > len(zoneName)) { + zoneID = zone.ID + zoneName = zone.Name + } + } + + if zoneID == "" { + return fmt.Errorf("未找到域名 %s 的Cloudflare Zone", domain) + } + + // 创建TXT记录 + record := cloudflare.DNSRecord{ + Type: "TXT", + Name: recordName, + Content: recordValue, + TTL: 120, + } + + _, err = ds.CloudflareAPI.CreateDNSRecord(zoneID, record) + if err != nil { + return fmt.Errorf("创建DNS TXT记录失败: %w", err) + } + + log.Info("DNS TXT记录已创建: %s = %s", recordName, recordValue) + + // 等待DNS传播 + log.Info("等待DNS记录传播...") + time.Sleep(30 * time.Second) + + return nil +} + +// CleanupTXTRecord 清理DNS TXT记录 +// +// 参数: +// - domain: 域名 +// - token: 验证token +// +// 返回: +// - error: 错误信息 +func (ds *DNSSolver) CleanupTXTRecord(domain, token 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 := ds.CloudflareAPI.ListZones(nil) + if err != nil { + return fmt.Errorf("获取Cloudflare zones失败: %w", err) + } + + var zoneID string + zoneName := "" + for _, zone := range zones { + domainToCheck := domain + if strings.HasPrefix(domain, "*.") { + domainToCheck = domain[2:] + } + + if strings.HasSuffix(domainToCheck, zone.Name) && (zoneName == "" || len(zone.Name) > len(zoneName)) { + zoneID = zone.ID + zoneName = zone.Name + } + } + + if zoneID == "" { + return fmt.Errorf("未找到域名 %s 的Cloudflare Zone", domain) + } + + // 查找TXT记录 + filter := &cloudflare.DNSRecordFilter{ + Type: "TXT", + Name: recordName, + } + + records, err := ds.CloudflareAPI.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 = ds.CloudflareAPI.DeleteDNSRecord(zoneID, record.ID) + if err != nil { + return fmt.Errorf("删除DNS TXT记录失败: %w", err) + } + log.Info("DNS TXT记录已删除: %s", recordName) + } + } + + return nil +} + +// IssueCertificate 颁发证书 +// +// 参数: +// - domain: 域名 +// - email: 注册邮箱 +// - directoryURL: ACME目录URL +// - privateKey: 私钥 +// - certDir: 证书保存目录 +// - dnsSolver: DNS验证器 +// +// 返回: +// - *CertInfo: 证书信息 +// - error: 错误信息 +func IssueCertificate(domain, email, directoryURL string, privateKey *ecdsa.PrivateKey, certDir string, dnsSolver *DNSSolver) (*CertInfo, error) { + log.Info("开始为域名 %s 颁发证书", domain) + + // 创建ACME客户端 + acmeClient, err := NewACMEClient(directoryURL, email, dnsSolver.CloudflareAPI) + if err != nil { + log.Error("创建ACME客户端失败: %v", err) + return nil, fmt.Errorf("创建ACME客户端失败: %w", err) + } + + // 使用ACME客户端获取证书 + certInfo, err := acmeClient.ObtainCertificate(domain, privateKey, certDir) + if err != nil { + log.Error("获取证书失败: %v", err) + return nil, fmt.Errorf("获取证书失败: %w", err) + } + + log.Info("证书颁发成功: %s", domain) + return certInfo, nil +} diff --git a/agent-wdd/cloudflare/Cloudflare.go b/agent-wdd/cloudflare/Cloudflare.go new file mode 100644 index 0000000..f92d148 --- /dev/null +++ b/agent-wdd/cloudflare/Cloudflare.go @@ -0,0 +1,153 @@ +package cloudflare + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "agent-wdd/log" +) + +const ( + // CloudflareAPIBaseURL is the base URL for Cloudflare API + CloudflareAPIBaseURL = "https://api.cloudflare.com/client/v4" + // DefaultTimeout is the default timeout for HTTP requests + DefaultTimeout = 5 * time.Second +) + +// CloudflareClient represents a client for interacting with Cloudflare API +type CloudflareClient struct { + apiToken string + baseURL string + client *http.Client + userAgent string +} + +var ( + instance *CloudflareClient + once sync.Once +) + +// Response is the general response structure from Cloudflare API +type Response struct { + Result interface{} `json:"result"` + ResultInfo *ResultInfo `json:"result_info,omitempty"` + Success bool `json:"success"` + Errors []Error `json:"errors"` + Messages []interface{} `json:"messages"` +} + +// ResultInfo contains pagination information +type ResultInfo struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` + Count int `json:"count"` + TotalCount int `json:"total_count"` +} + +// Error represents an error returned by the Cloudflare API +type Error struct { + Code int `json:"code"` + Message string `json:"message"` + ErrorChain []Error `json:"error_chain,omitempty"` +} + +// NewClient creates a new instance of CloudflareClient with the given API token +func NewClient(apiToken string) *CloudflareClient { + log.Debug("Creating new Cloudflare client") + return &CloudflareClient{ + apiToken: apiToken, + baseURL: CloudflareAPIBaseURL, + client: &http.Client{Timeout: DefaultTimeout}, + userAgent: "WddSuperAgent/1.0", + } +} + +// GetInstance returns the singleton instance of CloudflareClient +func GetInstance(apiToken string) *CloudflareClient { + if instance == nil { + once.Do(func() { + instance = NewClient(apiToken) + log.Info("Cloudflare client singleton instance created") + }) + } + return instance +} + +// SetAPIToken updates the API token used for authentication +func (c *CloudflareClient) SetAPIToken(apiToken string) { + c.apiToken = apiToken + log.Info("Cloudflare API token updated") +} + +// SetTimeout updates the HTTP client timeout +func (c *CloudflareClient) SetTimeout(timeout time.Duration) { + c.client.Timeout = timeout + log.Info("Cloudflare client timeout updated to %v", timeout) +} + +// doRequest performs an HTTP request with the given method, URL, and body +func (c *CloudflareClient) doRequest(method, url string, body interface{}) (*Response, error) { + log.Debug("Performing %s request to %s", method, url) + + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + log.Error("Failed to marshal request body: %v", err) + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonBody) + } + + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + log.Error("Failed to create HTTP request: %v", err) + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.userAgent) + + // Perform request + resp, err := c.client.Do(req) + if err != nil { + log.Error("Failed to execute HTTP request: %v", err) + return nil, fmt.Errorf("failed to execute HTTP request: %w", err) + } + defer resp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Error("Failed to read response body: %v", err) + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Parse response + var cfResp Response + if err := json.Unmarshal(respBody, &cfResp); err != nil { + log.Error("Failed to unmarshal response: %v", err) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check for API errors + if !cfResp.Success { + errorMsg := "Cloudflare API error" + if len(cfResp.Errors) > 0 { + errorMsg = fmt.Sprintf("%s: %s (code: %d)", errorMsg, cfResp.Errors[0].Message, cfResp.Errors[0].Code) + } + log.Error(errorMsg) + return &cfResp, fmt.Errorf(errorMsg) + } + + log.Debug("Request completed successfully") + return &cfResp, nil +} diff --git a/agent-wdd/cloudflare/Cloudflare_test.go b/agent-wdd/cloudflare/Cloudflare_test.go new file mode 100644 index 0000000..3c8b900 --- /dev/null +++ b/agent-wdd/cloudflare/Cloudflare_test.go @@ -0,0 +1,310 @@ +package cloudflare + +import ( + "agent-wdd/utils" + "fmt" + "testing" +) + +// 测试前的设置函数,获取API token并检查环境设置 +func setupTest(t *testing.T) *CloudflareClient { + // 从环境变量获取API令牌 + apiToken := "T7LxBemfe8SNGWkT9uz2XIc1e22ifAbBv_POJvDP" + + // 创建Cloudflare客户端实例 + return GetInstance(apiToken) +} + +// TestClientCreation 测试客户端创建 +func TestClientCreation(t *testing.T) { + client := setupTest(t) + if client == nil { + t.Fatal("客户端创建失败") + } +} + +// TestListZones 测试获取域名列表 +func TestListZones(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + t.Logf("找到 %d 个域名", len(zones)) + for _, zone := range zones { + t.Logf("域名: %s (ID: %s, 状态: %s)", zone.Name, zone.ID, zone.Status) + + utils.BeautifulPrint(zone) + } +} + +// TestGetZone 测试获取单个域名详情 +func TestGetZone(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + // 如果没有域名,则跳过测试 + if len(zones) == 0 { + t.Skip("没有找到域名,跳过测试") + } + + // 使用第一个域名进行测试 + zoneID := zones[0].ID + zoneName := zones[0].Name + + // 获取特定域名的详细信息 + zone, err := client.GetZone(zoneID) + if err != nil { + t.Fatalf("获取域名详情失败: %v", err) + } + + t.Logf("域名 %s 的详情:", zoneName) + t.Logf(" - ID: %s", zone.ID) + t.Logf(" - 状态: %s", zone.Status) + t.Logf(" - 名称服务器: %v", zone.NameServers) + t.Logf(" - 创建时间: %s", zone.CreatedOn) + + utils.BeautifulPrint(zone) +} + +// TestListDNSRecords 测试获取DNS记录列表 +func TestListDNSRecords(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + // 如果没有域名,则跳过测试 + if len(zones) == 0 { + t.Skip("没有找到域名,跳过测试") + } + + // 使用第一个域名进行测试 + zoneID := zones[0].ID + zoneName := zones[0].Name + + // 列出域名下的所有DNS记录 + records, err := client.ListDNSRecords(zoneID, nil) + if err != nil { + t.Fatalf("获取DNS记录列表失败: %v", err) + } + + t.Logf("在域名 %s 中找到 %d 条DNS记录", zoneName, len(records)) + for i, record := range records { + if i < 5 { // 只打印前5条记录,避免输出过多 + t.Logf(" - 记录 %d: %s (类型: %s, 内容: %s)", i+1, record.Name, record.Type, record.Content) + } + } +} + +// TestCreateUpdateDeleteDNSRecord 测试DNS记录的创建、更新和删除 +func TestCreateUpdateDeleteDNSRecord(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + // 如果没有域名,则跳过测试 + if len(zones) == 0 { + t.Skip("没有找到域名,跳过测试") + } + + // 使用第一个域名进行测试 + zoneID := zones[0].ID + zoneName := zones[0].Name + + // 创建新的DNS记录 + testRecordName := fmt.Sprintf("test-api.%s", zoneName) + newRecord := DNSRecord{ + Type: "A", + Name: testRecordName, + Content: "192.0.2.1", // 示例IP地址 + TTL: 3600, // 1小时 + Proxied: false, // 不使用Cloudflare代理 + Comment: "自动化测试记录", + } + + // 检查记录是否已存在 + existingRecord, err := client.FindDNSRecord(zoneID, newRecord.Name, newRecord.Type) + if err != nil { + t.Fatalf("检查已存在记录失败: %v", err) + } + + var recordID string + + // 测试创建或更新记录 + if existingRecord != nil { + // 记录已存在,更新它 + t.Logf("记录 %s 已存在,正在更新", newRecord.Name) + updatedRecord, err := client.UpdateDNSRecord(zoneID, existingRecord.ID, newRecord) + if err != nil { + t.Fatalf("更新DNS记录失败: %v", err) + } + t.Logf("成功更新DNS记录: %s", updatedRecord.Name) + recordID = updatedRecord.ID + } else { + // 创建新记录 + createdRecord, err := client.CreateDNSRecord(zoneID, newRecord) + if err != nil { + t.Fatalf("创建DNS记录失败: %v", err) + } + t.Logf("成功创建DNS记录: %s", createdRecord.Name) + recordID = createdRecord.ID + } + + // 测试获取特定DNS记录详情 + record, err := client.GetDNSRecord(zoneID, recordID) + if err != nil { + t.Fatalf("获取DNS记录详情失败: %v", err) + } + + t.Logf("DNS记录详情:") + t.Logf(" - ID: %s", record.ID) + t.Logf(" - 名称: %s", record.Name) + t.Logf(" - 类型: %s", record.Type) + t.Logf(" - 内容: %s", record.Content) + t.Logf(" - TTL: %d", record.TTL) + t.Logf(" - 已代理: %t", record.Proxied) + + // 测试删除DNS记录 + success, err := client.DeleteDNSRecord(zoneID, recordID) + if err != nil { + t.Fatalf("删除DNS记录失败: %v", err) + } + + if success { + t.Logf("成功删除DNS记录: %s", record.Name) + } else { + t.Errorf("删除DNS记录失败: %s", record.Name) + } +} + +// TestGetZoneIDByName 测试通过域名获取域名ID +func TestGetZoneIDByName(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + // 如果没有域名,则跳过测试 + if len(zones) == 0 { + t.Skip("没有找到域名,跳过测试") + } + + // 使用第一个域名进行测试 + zoneName := zones[0].Name + expectedID := zones[0].ID + + // 测试辅助函数 + id, err := getZoneIDByName(client, zoneName) + if err != nil { + t.Fatalf("通过名称获取域名ID失败: %v", err) + } + + if id != expectedID { + t.Errorf("获取的域名ID不匹配: 期望 %s, 实际 %s", expectedID, id) + } else { + t.Logf("成功通过名称获取域名ID: %s", id) + } +} + +// TestCreateOrUpdateDNSRecord 测试创建或更新DNS记录辅助函数 +func TestCreateOrUpdateDNSRecord(t *testing.T) { + client := setupTest(t) + + // 列出所有域名 + zones, err := client.ListZones(nil) + if err != nil { + t.Fatalf("获取域名列表失败: %v", err) + } + + // 如果没有域名,则跳过测试 + if len(zones) == 0 { + t.Skip("没有找到域名,跳过测试") + } + + // 使用第一个域名进行测试 + zoneID := zones[0].ID + zoneName := zones[0].Name + + // 创建测试记录 + testRecordName := fmt.Sprintf("test-helper.%s", zoneName) + record := DNSRecord{ + Type: "A", + Name: testRecordName, + Content: "192.0.2.2", // 示例IP地址 + TTL: 1800, // 30分钟 + Proxied: false, + Comment: "测试辅助函数创建的记录", + } + + // 测试辅助函数 + updatedRecord, err := createOrUpdateDNSRecord(client, zoneID, record) + if err != nil { + t.Fatalf("创建或更新DNS记录失败: %v", err) + } + + t.Logf("通过辅助函数成功创建或更新DNS记录: %s", updatedRecord.Name) + + // 清理: 删除测试记录 + if updatedRecord != nil && updatedRecord.ID != "" { + success, err := client.DeleteDNSRecord(zoneID, updatedRecord.ID) + if err != nil { + t.Logf("清理时删除DNS记录失败: %v", err) + } + + if success { + t.Logf("清理: 成功删除测试DNS记录") + } + } +} + +// 以下是从Example.go转移过来的辅助函数,为了在测试中使用 + +// getZoneIDByName 通过域名获取对应的Zone ID +func getZoneIDByName(client *CloudflareClient, name string) (string, error) { + zones, err := client.ListZones(&ZoneFilter{Name: name}) + if err != nil { + return "", err + } + + if len(zones) == 0 { + return "", fmt.Errorf("no zone found with name: %s", name) + } + + return zones[0].ID, nil +} + +// createOrUpdateDNSRecord 创建或更新DNS记录 +func createOrUpdateDNSRecord(client *CloudflareClient, zoneID string, record DNSRecord) (*DNSRecord, error) { + // 查找是否已存在相同名称和类型的记录 + existingRecord, err := client.FindDNSRecord(zoneID, record.Name, record.Type) + if err != nil { + return nil, err + } + + if existingRecord != nil { + // 更新现有记录 + return client.UpdateDNSRecord(zoneID, existingRecord.ID, record) + } + + // 创建新记录 + return client.CreateDNSRecord(zoneID, record) +} diff --git a/agent-wdd/cloudflare/DNS.go b/agent-wdd/cloudflare/DNS.go new file mode 100644 index 0000000..724a542 --- /dev/null +++ b/agent-wdd/cloudflare/DNS.go @@ -0,0 +1,304 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "agent-wdd/log" +) + +// DNSRecord represents a DNS record in Cloudflare +type DNSRecord struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + Priority int `json:"priority,omitempty"` + Proxiable bool `json:"proxiable,omitempty"` + Proxied bool `json:"proxied"` + TTL int `json:"ttl"` + Settings map[string]interface{} `json:"settings,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` + Comment string `json:"comment,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedOn string `json:"created_on,omitempty"` + ModifiedOn string `json:"modified_on,omitempty"` + CommentModifiedOn string `json:"comment_modified_on,omitempty"` +} + +// DNSRecordFilter represents filters for listing DNS records +type DNSRecordFilter struct { + Type string + Name string + Content string + Page int + PerPage int + Order string + Direction string + Match string +} + +// ListDNSRecords retrieves all DNS records for a zone +// +// Parameters: +// - zoneID: The zone ID +// - filter: Optional filter to apply to the DNS records list +// +// Returns: +// - []DNSRecord: List of DNS records +// - error: Any errors that occurred during the request +func (c *CloudflareClient) ListDNSRecords(zoneID string, filter *DNSRecordFilter) ([]DNSRecord, error) { + log.Debug("Listing DNS records for zone ID: %s with filter: %+v", zoneID, filter) + + // Build URL and query parameters + endpoint := fmt.Sprintf("%s/zones/%s/dns_records", c.baseURL, zoneID) + u, err := url.Parse(endpoint) + if err != nil { + log.Error("Failed to parse URL: %v", err) + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + + q := u.Query() + if filter != nil { + if filter.Type != "" { + q.Add("type", filter.Type) + } + if filter.Name != "" { + q.Add("name", filter.Name) + } + if filter.Content != "" { + q.Add("content", filter.Content) + } + if filter.Page > 0 { + q.Add("page", strconv.Itoa(filter.Page)) + } + if filter.PerPage > 0 { + q.Add("per_page", strconv.Itoa(filter.PerPage)) + } + if filter.Order != "" { + q.Add("order", filter.Order) + } + if filter.Direction != "" { + q.Add("direction", filter.Direction) + } + if filter.Match != "" { + q.Add("match", filter.Match) + } + } + u.RawQuery = q.Encode() + + // Make request + resp, err := c.doRequest(http.MethodGet, u.String(), nil) + if err != nil { + log.Error("Failed to list DNS records: %v", err) + return nil, fmt.Errorf("failed to list DNS records: %w", err) + } + + // Parse response + var records []DNSRecord + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &records); err != nil { + log.Error("Failed to unmarshal DNS records: %v", err) + return nil, fmt.Errorf("failed to unmarshal DNS records: %w", err) + } + + log.Info("Successfully retrieved %d DNS records for zone ID: %s", len(records), zoneID) + return records, nil +} + +// CreateDNSRecord creates a new DNS record in the specified zone +// +// Parameters: +// - zoneID: The zone ID +// - record: The DNS record to create +// +// Returns: +// - *DNSRecord: The created DNS record +// - error: Any errors that occurred during the request +func (c *CloudflareClient) CreateDNSRecord(zoneID string, record DNSRecord) (*DNSRecord, error) { + log.Debug("Creating DNS record in zone ID: %s with data: %+v", zoneID, record) + + // Build URL + endpoint := fmt.Sprintf("%s/zones/%s/dns_records", c.baseURL, zoneID) + + // Make request + resp, err := c.doRequest(http.MethodPost, endpoint, record) + if err != nil { + log.Error("Failed to create DNS record: %v", err) + return nil, fmt.Errorf("failed to create DNS record: %w", err) + } + + // Parse response + var createdRecord DNSRecord + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &createdRecord); err != nil { + log.Error("Failed to unmarshal created DNS record: %v", err) + return nil, fmt.Errorf("failed to unmarshal created DNS record: %w", err) + } + + log.Info("Successfully created DNS record: %s (%s) in zone ID: %s", createdRecord.Name, createdRecord.ID, zoneID) + return &createdRecord, nil +} + +// GetDNSRecord retrieves a specific DNS record by ID +// +// Parameters: +// - zoneID: The zone ID +// - recordID: The DNS record ID +// +// Returns: +// - *DNSRecord: The DNS record +// - error: Any errors that occurred during the request +func (c *CloudflareClient) GetDNSRecord(zoneID, recordID string) (*DNSRecord, error) { + log.Debug("Getting DNS record ID: %s in zone ID: %s", recordID, zoneID) + + // Build URL + endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", c.baseURL, zoneID, recordID) + + // Make request + resp, err := c.doRequest(http.MethodGet, endpoint, nil) + if err != nil { + log.Error("Failed to get DNS record: %v", err) + return nil, fmt.Errorf("failed to get DNS record: %w", err) + } + + // Parse response + var record DNSRecord + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &record); err != nil { + log.Error("Failed to unmarshal DNS record: %v", err) + return nil, fmt.Errorf("failed to unmarshal DNS record: %w", err) + } + + log.Info("Successfully retrieved DNS record: %s (%s) from zone ID: %s", record.Name, record.ID, zoneID) + return &record, nil +} + +// UpdateDNSRecord updates an existing DNS record +// +// Parameters: +// - zoneID: The zone ID +// - recordID: The DNS record ID +// - record: The updated DNS record data +// +// Returns: +// - *DNSRecord: The updated DNS record +// - error: Any errors that occurred during the request +func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, record DNSRecord) (*DNSRecord, error) { + log.Debug("Updating DNS record ID: %s in zone ID: %s with data: %+v", recordID, zoneID, record) + + // Build URL + endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", c.baseURL, zoneID, recordID) + + // Make request + resp, err := c.doRequest(http.MethodPut, endpoint, record) + if err != nil { + log.Error("Failed to update DNS record: %v", err) + return nil, fmt.Errorf("failed to update DNS record: %w", err) + } + + // Parse response + var updatedRecord DNSRecord + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &updatedRecord); err != nil { + log.Error("Failed to unmarshal updated DNS record: %v", err) + return nil, fmt.Errorf("failed to unmarshal updated DNS record: %w", err) + } + + log.Info("Successfully updated DNS record: %s (%s) in zone ID: %s", updatedRecord.Name, updatedRecord.ID, zoneID) + return &updatedRecord, nil +} + +// DeleteDNSRecord deletes a DNS record +// +// Parameters: +// - zoneID: The zone ID +// - recordID: The DNS record ID +// +// Returns: +// - bool: True if the record was deleted successfully +// - error: Any errors that occurred during the request +func (c *CloudflareClient) DeleteDNSRecord(zoneID, recordID string) (bool, error) { + log.Debug("Deleting DNS record ID: %s in zone ID: %s", recordID, zoneID) + + // Build URL + endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", c.baseURL, zoneID, recordID) + + // Make request + resp, err := c.doRequest(http.MethodDelete, endpoint, nil) + if err != nil { + log.Error("Failed to delete DNS record: %v", err) + return false, fmt.Errorf("failed to delete DNS record: %w", err) + } + + // Parse response + result, ok := resp.Result.(map[string]interface{}) + if !ok { + log.Error("Unexpected response format for DNS record deletion") + return false, fmt.Errorf("unexpected response format for DNS record deletion") + } + + id, ok := result["id"].(string) + if !ok { + log.Error("Failed to extract ID from deletion response") + return false, fmt.Errorf("failed to extract ID from deletion response") + } + + log.Info("Successfully deleted DNS record ID: %s from zone ID: %s", id, zoneID) + return true, nil +} + +// FindDNSRecord finds a DNS record by name and type +// +// Parameters: +// - zoneID: The zone ID +// - name: The DNS record name +// - recordType: The DNS record type (A, AAAA, CNAME, etc.) +// +// Returns: +// - *DNSRecord: The found DNS record, or nil if not found +// - error: Any errors that occurred during the request +func (c *CloudflareClient) FindDNSRecord(zoneID, name, recordType string) (*DNSRecord, error) { + log.Debug("Finding DNS record with name: %s and type: %s in zone ID: %s", name, recordType, zoneID) + + filter := &DNSRecordFilter{ + Name: name, + Type: recordType, + } + + records, err := c.ListDNSRecords(zoneID, filter) + if err != nil { + return nil, err + } + + if len(records) == 0 { + log.Warning("No DNS record found with name: %s and type: %s in zone ID: %s", name, recordType, zoneID) + return nil, nil + } + + log.Info("Found DNS record: %s (%s) with type: %s in zone ID: %s", records[0].Name, records[0].ID, records[0].Type, zoneID) + return &records[0], nil +} diff --git a/agent-wdd/cloudflare/Zone.go b/agent-wdd/cloudflare/Zone.go new file mode 100644 index 0000000..5caa18e --- /dev/null +++ b/agent-wdd/cloudflare/Zone.go @@ -0,0 +1,218 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "agent-wdd/log" +) + +// Zone represents a Cloudflare zone (domain) +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Paused bool `json:"paused"` + Type string `json:"type"` + DevelopmentMode int `json:"development_mode"` + NameServers []string `json:"name_servers"` + OriginalNameServers []string `json:"original_name_servers"` + OriginalRegistrar interface{} `json:"original_registrar"` + OriginalDNSHost interface{} `json:"original_dnshost"` + ModifiedOn string `json:"modified_on"` + CreatedOn string `json:"created_on"` + ActivatedOn string `json:"activated_on"` + Meta ZoneMeta `json:"meta"` + Owner Owner `json:"owner"` + Account Account `json:"account"` + Tenant Tenant `json:"tenant"` + TenantUnit TenantUnit `json:"tenant_unit"` + Permissions []string `json:"permissions"` + Plan Plan `json:"plan"` +} + +// ZoneMeta contains zone metadata +type ZoneMeta struct { + Step int `json:"step"` + CustomCertificateQuota int `json:"custom_certificate_quota"` + PageRuleQuota int `json:"page_rule_quota"` + PhishingDetected bool `json:"phishing_detected"` +} + +// Owner represents the owner of a zone +type Owner struct { + ID interface{} `json:"id"` + Type string `json:"type"` + Email interface{} `json:"email"` +} + +// Account represents a Cloudflare account +type Account struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Tenant represents a tenant +type Tenant struct { + ID interface{} `json:"id"` + Name interface{} `json:"name"` +} + +// TenantUnit represents a tenant unit +type TenantUnit struct { + ID interface{} `json:"id"` +} + +// Plan represents a zone plan +type Plan struct { + ID string `json:"id"` + Name string `json:"name"` + Price int `json:"price"` + Currency string `json:"currency"` + Frequency string `json:"frequency"` + IsSubscribed bool `json:"is_subscribed"` + CanSubscribe bool `json:"can_subscribe"` + LegacyID string `json:"legacy_id"` + LegacyDiscount bool `json:"legacy_discount"` + ExternallyManaged bool `json:"externally_managed"` +} + +// ZoneFilter represents filters for listing zones +type ZoneFilter struct { + Name string + Status string + Page int + PerPage int + Direction string + Match string +} + +// ListZones retrieves a list of zones from Cloudflare based on the provided filters +// +// Parameters: +// - filter: Optional filter to apply to the zone list +// +// Returns: +// - []Zone: List of zones +// - error: Any errors that occurred during the request +func (c *CloudflareClient) ListZones(filter *ZoneFilter) ([]Zone, error) { + log.Debug("Listing zones with filter: %+v", filter) + + // Build URL and query parameters + endpoint := fmt.Sprintf("%s/zones", c.baseURL) + u, err := url.Parse(endpoint) + if err != nil { + log.Error("Failed to parse URL: %v", err) + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + + q := u.Query() + if filter != nil { + if filter.Name != "" { + q.Add("name", filter.Name) + } + if filter.Status != "" { + q.Add("status", filter.Status) + } + if filter.Page > 0 { + q.Add("page", fmt.Sprintf("%d", filter.Page)) + } + if filter.PerPage > 0 { + q.Add("per_page", fmt.Sprintf("%d", filter.PerPage)) + } + if filter.Direction != "" { + q.Add("direction", filter.Direction) + } + if filter.Match != "" { + q.Add("match", filter.Match) + } + } + u.RawQuery = q.Encode() + + // Make request + resp, err := c.doRequest(http.MethodGet, u.String(), nil) + if err != nil { + log.Error("Failed to list zones: %v", err) + return nil, fmt.Errorf("failed to list zones: %w", err) + } + + // Parse response + var zones []Zone + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &zones); err != nil { + log.Error("Failed to unmarshal zones: %v", err) + return nil, fmt.Errorf("failed to unmarshal zones: %w", err) + } + + log.Info("Successfully retrieved %d zones", len(zones)) + return zones, nil +} + +// GetZone retrieves details of a specific zone by ID or name +// +// Parameters: +// - identifier: The zone ID or domain name +// +// Returns: +// - *Zone: The zone details +// - error: Any errors that occurred during the request +func (c *CloudflareClient) GetZone(identifier string) (*Zone, error) { + log.Debug("Getting zone details for identifier: %s", identifier) + + // Determine if identifier is an ID or a domain name + isID := true + for _, char := range identifier { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { + isID = false + break + } + } + + var endpoint string + if isID { + endpoint = fmt.Sprintf("%s/zones/%s", c.baseURL, identifier) + } else { + // List zones with name filter + zones, err := c.ListZones(&ZoneFilter{Name: identifier}) + if err != nil { + return nil, err + } + + if len(zones) == 0 { + log.Error("No zone found with name: %s", identifier) + return nil, fmt.Errorf("no zone found with name: %s", identifier) + } + + endpoint = fmt.Sprintf("%s/zones/%s", c.baseURL, zones[0].ID) + } + + // Make request + resp, err := c.doRequest(http.MethodGet, endpoint, nil) + if err != nil { + log.Error("Failed to get zone: %v", err) + return nil, fmt.Errorf("failed to get zone: %w", err) + } + + // Parse response + var zone Zone + result, err := json.Marshal(resp.Result) + if err != nil { + log.Error("Failed to marshal result: %v", err) + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + if err := json.Unmarshal(result, &zone); err != nil { + log.Error("Failed to unmarshal zone: %v", err) + return nil, fmt.Errorf("failed to unmarshal zone: %w", err) + } + + log.Info("Successfully retrieved zone: %s (%s)", zone.Name, zone.ID) + return &zone, nil +} diff --git a/agent-wdd/cmd/Acme.go b/agent-wdd/cmd/Acme.go index 2327c7f..b49923b 100644 --- a/agent-wdd/cmd/Acme.go +++ b/agent-wdd/cmd/Acme.go @@ -1,10 +1,260 @@ package cmd import ( + "agent-wdd/log" + "agent-wdd/op" + "agent-wdd/utils" + "os" + "strings" + "time" + "github.com/spf13/cobra" ) +const ( + acmeShUrl = "https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh" + CF_Token = "oXJRP5XI8Zhipa_PtYtB_jy6qWL0I9BosrJEYE8p" + CF_Account_ID = "dfaadeb83406ef5ad35da02617af9191" + CF_Zone_ID = "511894a4f1357feb905e974e16241ebb" +) + // addAcmeSubcommands acme的相关任务 func addAcmeSubcommands(cmd *cobra.Command) { + // install + installCmd := &cobra.Command{ + Use: "install", + Short: "安装acme", + Run: func(cmd *cobra.Command, args []string) { + log.Info("安装acme") + + // 检查是否安装acme + if utils.FileExistAndNotNull("/usr/local/bin/acme.sh") { + log.Info("acme已安装") + return + } + + // 下载 这个文件到 /usr/local/bin/acme.sh + ok, err := utils.DownloadFile( + acmeShUrl, + "/usr/local/bin/acme.sh", + ) + if !ok { + log.Error("下载acme.sh失败", err) + return + } + + // 设置权限 + utils.PermissionFileExecute("/usr/local/bin/acme.sh") + + // 执行安装命令 + op.RealTimeCommandExecutor([]string{ + "/usr/local/bin/acme.sh", + "--install-online", + "ice@gmail.com", + }) + + log.Info("acme安装成功") + }, + } + + // renew + renewCmd := &cobra.Command{ + Use: "renew", + Short: "acme续期", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + log.Info("acme续期") + + domain_name := args[0] + // 检查domain_name是否是有效的域名 + if !strings.HasSuffix(domain_name, "107421.xyz") { + log.Error("只支持续期107421.xyz的域名") + return + } + + // 注入环境变量 + os.Setenv("CF_Token", CF_Token) + os.Setenv("CF_Account_ID", CF_Account_ID) + os.Setenv("CF_Zone_ID", CF_Zone_ID) + + // 执行命令 + op.RealTimeCommandExecutor([]string{ + "/root/.acme.sh/acme.sh", + "--renew", + "-d", + domain_name, + }) + + // 删除环境变量 + os.Unsetenv("CF_Token") + os.Unsetenv("CF_Account_ID") + os.Unsetenv("CF_Zone_ID") + + log.Info("续期acme成功") + }, + } + + // list + listCmd := &cobra.Command{ + Use: "list", + Short: "列出acme全部的证书", + Run: func(cmd *cobra.Command, args []string) { + log.Info("列出acme全部的证书") + + // 执行命令 + ok, output := op.SingleLineCommandExecutor([]string{"/root/.acme.sh/acme.sh", "--list"}) + if !ok { + log.Error("列出acme全部的证书失败", output) + return + } + + utils.BeautifulPrintListWithTitle(output, "列出acme全部的证书") + + // 获取当前时间 + now := time.Now() + // 设置30天的期限 + expiryLimit := now.AddDate(0, 0, 30) + + log.Info("以下证书将在30天内过期:") + foundExpiring := false + + // 跳过标题行 + for i := 1; i < len(output); i++ { + line := strings.TrimSpace(output[i]) + if line == "" { + continue + } + + // 分割行内容 + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + + // 获取域名和更新时间 + domainName := fields[0] + renewDateStr := fields[len(fields)-1] + + // 解析更新时间 + renewDate, err := time.Parse(time.RFC3339, renewDateStr) + if err != nil { + log.Error("解析时间失败: %s", err.Error()) + continue + } + + // 检查是否在30天内过期 + if renewDate.Before(expiryLimit) { + log.Info("域名: %s, 更新时间: %s", domainName, renewDate.Format("2006-01-02")) + foundExpiring = true + } + } + + if !foundExpiring { + log.Info("没有找到30天内即将过期的证书") + } + }, + } + + // revoke + revokeCmd := &cobra.Command{ + Use: "revoke", + Short: "撤销acme", + Run: func(cmd *cobra.Command, args []string) { + log.Info("撤销acme") + + // 执行命令 + op.RealTimeCommandExecutor([]string{"acme.sh", "revoke"}) + }, + } + + // 申请一个证书 + applyCmd := &cobra.Command{ + Use: "apply", + Short: "申请一个证书", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + log.Info("申请一个证书") + + domain_name := args[0] + // 检查domain_name是否是有效的域名 + if !strings.HasSuffix(domain_name, "107421.xyz") { + log.Error("只支持申请107421.xyz的域名") + return + } + + // 注入环境变量 + os.Setenv("CF_Token", CF_Token) + os.Setenv("CF_Account_ID", CF_Account_ID) + os.Setenv("CF_Zone_ID", CF_Zone_ID) + + // 执行命令 + op.RealTimeCommandExecutor([]string{ + "/root/.acme.sh/acme.sh", + "--issue", + "--dns", + "dns_cf", + "-d", + domain_name, + "--keylength", + "ec-256", + }) + + // 删除环境变量 + os.Unsetenv("CF_Token") + os.Unsetenv("CF_Account_ID") + os.Unsetenv("CF_Zone_ID") + + log.Info("申请证书成功") + }, + } + + // 安装证书 + installNginxCmd := &cobra.Command{ + Use: "nginx", + Short: "安装nginx证书", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + log.Info("安装nginx证书") + + domain_name := args[0] + // 检查domain_name是否是有效的域名 + if !strings.HasSuffix(domain_name, "107421.xyz") { + log.Error("只支持安装107421.xyz的域名") + return + } + + nginx_conf_dir := "/etc/nginx/conf.d/ssl_key/" + nginx_ssl_key_full_path := nginx_conf_dir + domain_name + ".key.pem" + nginx_ssl_cert_full_path := nginx_conf_dir + domain_name + ".cert.pem" + + // 检查nginx_conf_dir_full_path是否存在 + utils.CreateFolder(nginx_conf_dir) + + // 执行命令 + op.RealTimeCommandExecutor([]string{ + "/root/.acme.sh/acme.sh", + "--install-cert", + "-d", + domain_name, + "--key-file", + nginx_ssl_key_full_path, + "--fullchain-file", + nginx_ssl_cert_full_path, + "--reloadcmd", + "systemctl restart nginx --force", + }) + + log.Info("安装nginx证书成功") + }, + } + + cmd.AddCommand( + installCmd, + renewCmd, + listCmd, + revokeCmd, + applyCmd, + installNginxCmd, + ) } diff --git a/agent-wdd/cmd/CertManager.go b/agent-wdd/cmd/CertManager.go new file mode 100644 index 0000000..2fb4486 --- /dev/null +++ b/agent-wdd/cmd/CertManager.go @@ -0,0 +1,258 @@ +package cmd + +import ( + "agent-wdd/cert_manager_wdd" + "agent-wdd/log" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +var ( + // 证书管理命令选项 + cfAPIToken string + certDir string + emailAddr string + caServer string + daysRenewal int +) + +// 初始化证书管理配置 +func initCertManagerConfig() *cert_manager_wdd.CertManager { + // 如果未指定API令牌,提示用户 + if cfAPIToken == "" { + log.Error("未指定Cloudflare API令牌,请使用--token参数设置") + os.Exit(1) + } + + // 如果未指定邮箱地址,使用默认值 + if emailAddr == "" { + emailAddr = "cert@example.com" + log.Warning("未指定邮箱地址,使用默认值: %s", emailAddr) + } + + // 创建证书管理器 + certManager := cert_manager_wdd.NewCertManager(certDir, cfAPIToken, emailAddr) + + // 设置证书更新阈值天数 + if daysRenewal > 0 { + certManager.DaysBeforeRenewal = daysRenewal + } + + // 设置CA服务器 + if caServer != "" { + switch strings.ToLower(caServer) { + case "letsencrypt": + certManager.SetCAServer(cert_manager_wdd.LetsEncryptCA) + case "zerossl": + certManager.SetCAServer(cert_manager_wdd.ZeroSSLCA) + default: + log.Warning("未知的CA服务器: %s,使用默认值", caServer) + } + } + + return certManager +} + +// 添加证书管理命令 +func addCertManagerSubcommands(cmd *cobra.Command) { + // 全局标志 + cmd.PersistentFlags().StringVar(&cfAPIToken, "token", "", "Cloudflare API令牌") + cmd.PersistentFlags().StringVar(&certDir, "cert-dir", "", "证书保存目录") + cmd.PersistentFlags().StringVar(&emailAddr, "email", "", "申请证书使用的邮箱") + cmd.PersistentFlags().StringVar(&caServer, "ca", "letsencrypt", "CA服务器 (letsencrypt, zerossl)") + cmd.PersistentFlags().IntVar(&daysRenewal, "days", 30, "证书更新阈值天数") + + // 申请证书命令 + applyCmd := &cobra.Command{ + Use: "apply [域名]", + Short: "申请SSL证书", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + domain := args[0] + log.Info("开始为域名 %s 申请证书", domain) + + certManager := initCertManagerConfig() + certInfo, err := certManager.ApplyCertificate(domain) + if err != nil { + log.Error("证书申请失败: %v", err) + os.Exit(1) + } + + printCertificateInfo(certInfo) + log.Info("证书申请成功") + }, + } + + // 列出证书命令 + listCmd := &cobra.Command{ + Use: "list", + Short: "列出所有证书", + Run: func(cmd *cobra.Command, args []string) { + log.Info("列出所有证书") + + certManager := initCertManagerConfig() + certs, err := certManager.GetAllCerts() + if err != nil { + log.Error("获取证书列表失败: %v", err) + os.Exit(1) + } + + if len(certs) == 0 { + log.Info("未找到任何证书") + return + } + + printCertificatesList(certs) + }, + } + + // 查看证书命令 + showCmd := &cobra.Command{ + Use: "show [域名]", + Short: "查看指定证书的详细信息", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + domain := args[0] + log.Info("查看域名 %s 的证书信息", domain) + + certManager := initCertManagerConfig() + certs, err := certManager.GetAllCerts() + if err != nil { + log.Error("获取证书列表失败: %v", err) + os.Exit(1) + } + + var targetCert *cert_manager_wdd.CertInfo + for _, cert := range certs { + if cert.Domain == domain { + targetCert = &cert + break + } + } + + if targetCert == nil { + log.Error("未找到域名 %s 的证书", domain) + os.Exit(1) + } + + printCertificateDetailInfo(targetCert) + }, + } + + // 更新证书命令 + renewCmd := &cobra.Command{ + Use: "renew [域名]", + Short: "更新指定证书", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + domain := args[0] + log.Info("更新域名 %s 的证书", domain) + + certManager := initCertManagerConfig() + certInfo, err := certManager.RenewCertificate(domain) + if err != nil { + log.Error("证书更新失败: %v", err) + os.Exit(1) + } + + printCertificateInfo(certInfo) + log.Info("证书更新成功") + }, + } + + // 更新所有需要更新的证书 + renewAllCmd := &cobra.Command{ + Use: "renew-all", + Short: "更新所有需要更新的证书", + Run: func(cmd *cobra.Command, args []string) { + log.Info("更新所有需要更新的证书") + + certManager := initCertManagerConfig() + certs, err := certManager.GetAllCerts() + if err != nil { + log.Error("获取证书列表失败: %v", err) + os.Exit(1) + } + + needRenewalCount := 0 + for _, cert := range certs { + if cert.NeedsRenewal { + needRenewalCount++ + log.Info("证书 %s 需要更新 (剩余 %d 天)", cert.Domain, cert.DaysRemaining) + certInfo, err := certManager.RenewCertificate(cert.Domain) + if err != nil { + log.Error("更新证书 %s 失败: %v", cert.Domain, err) + continue + } + log.Info("证书 %s 更新成功", certInfo.Domain) + } + } + + if needRenewalCount == 0 { + log.Info("没有需要更新的证书") + } else { + log.Info("共更新 %d 个证书", needRenewalCount) + } + }, + } + + // 将所有子命令添加到主命令 + cmd.AddCommand(applyCmd, listCmd, showCmd, renewCmd, renewAllCmd) +} + +// 打印证书信息 +func printCertificateInfo(certInfo *cert_manager_wdd.CertInfo) { + fmt.Printf("域名: %s\n", certInfo.Domain) + fmt.Printf("注册时间: %s\n", certInfo.RegisteredAt.Format("2006-01-02 15:04:05")) + fmt.Printf("到期时间: %s\n", certInfo.ExpiresAt.Format("2006-01-02 15:04:05")) + fmt.Printf("剩余天数: %d\n", certInfo.DaysRemaining) + fmt.Printf("证书路径: %s\n", certInfo.CertPath) + fmt.Printf("密钥路径: %s\n", certInfo.KeyPath) + fmt.Printf("CA服务器: %s\n", certInfo.CAName) + fmt.Printf("是否需要更新: %v\n", certInfo.NeedsRenewal) + fmt.Printf("是否为通配符证书: %v\n", certInfo.WildcardCert) +} + +// 打印证书详细信息 +func printCertificateDetailInfo(certInfo *cert_manager_wdd.CertInfo) { + fmt.Printf("=============== 证书详情 ===============\n") + printCertificateInfo(certInfo) + fmt.Printf("=======================================\n") + + // 打印证书内容(可选) + fmt.Printf("\n证书内容:\n") + certContent, err := os.ReadFile(certInfo.CertPath) + if err == nil { + fmt.Println(string(certContent)) + } else { + fmt.Printf("无法读取证书内容: %v\n", err) + } +} + +// 打印证书列表 +func printCertificatesList(certs []cert_manager_wdd.CertInfo) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "域名\t到期时间\t剩余天数\t需要更新\tCA服务器") + fmt.Fprintln(w, "------\t--------\t--------\t--------\t--------") + + for _, cert := range certs { + needsRenewal := "否" + if cert.NeedsRenewal { + needsRenewal = "是" + } + + expiresAt := cert.ExpiresAt.Format("2006-01-02") + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", + cert.Domain, + expiresAt, + cert.DaysRemaining, + needsRenewal, + cert.CAName) + } + + w.Flush() +} diff --git a/agent-wdd/cmd/root.go b/agent-wdd/cmd/root.go index f8a58d9..71d3227 100644 --- a/agent-wdd/cmd/root.go +++ b/agent-wdd/cmd/root.go @@ -104,6 +104,13 @@ func Execute() { addDownloadSubcommands(downloadCmd) + // 11. cert命令 + certCmd := &cobra.Command{ + Use: "cert", + Short: "SSL证书管理", + } + addCertManagerSubcommands(certCmd) + helpCmd := &cobra.Command{ Use: "help", Short: "帮助信息", @@ -131,6 +138,7 @@ func Execute() { versionCmd, configCmd, downloadCmd, + certCmd, helpCmd, ) diff --git a/agent-wdd/config/Disk.go b/agent-wdd/config/Disk.go index 9209bfc..ee0e1c4 100644 --- a/agent-wdd/config/Disk.go +++ b/agent-wdd/config/Disk.go @@ -2,13 +2,10 @@ package config import ( "agent-wdd/log" - "agent-wdd/utils" "bufio" - "fmt" "os" "path/filepath" "strings" - "syscall" ) var CommonDiskPath = []string{ @@ -81,32 +78,33 @@ func DiskListGather() { //utils.BeautifulPrint(diskList) } +// 计算磁盘使用情况 func (disk *Disk) calculateDiskUsage() { - var stat syscall.Statfs_t - err := syscall.Statfs(disk.Path, &stat) - if err != nil { - log.Error("disk syscall error: %v", err) - disk.Size = "0B" - disk.Usage = "0B" - disk.Percent = "0.00%" - return - } + // var stat syscall.Statfs_t + // err := syscall.Statfs(disk.Path, &stat) + // if err != nil { + // log.Error("disk syscall error: %v", err) + // disk.Size = "0B" + // disk.Usage = "0B" + // disk.Percent = "0.00%" + // return + // } - // 计算存储空间大小 - totalBytes := stat.Blocks * uint64(stat.Bsize) - availBytes := stat.Bavail * uint64(stat.Bsize) - usedBytes := totalBytes - availBytes + // // 计算存储空间大小 + // totalBytes := stat.Blocks * uint64(stat.Bsize) + // availBytes := stat.Bavail * uint64(stat.Bsize) + // usedBytes := totalBytes - availBytes - // 格式化输出 - disk.Size = utils.HumanDiskSize(totalBytes) - disk.Usage = utils.HumanDiskSize(usedBytes) + // // 格式化输出 + // disk.Size = utils.HumanDiskSize(totalBytes) + // disk.Usage = utils.HumanDiskSize(usedBytes) - if totalBytes == 0 { - disk.Percent = "0.00%" - } else { - percent := float64(usedBytes) / float64(totalBytes) * 100 - disk.Percent = fmt.Sprintf("%.2f%%", percent) - } + // if totalBytes == 0 { + // disk.Percent = "0.00%" + // } else { + // percent := float64(usedBytes) / float64(totalBytes) * 100 + // disk.Percent = fmt.Sprintf("%.2f%%", percent) + // } } func DiskListSaveConfig() { diff --git a/agent-wdd/go.mod b/agent-wdd/go.mod index ea229d3..50c9189 100644 --- a/agent-wdd/go.mod +++ b/agent-wdd/go.mod @@ -24,10 +24,11 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.36.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.35.0 - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/agent-wdd/go.sum b/agent-wdd/go.sum index 22b16a1..40e5c5c 100644 --- a/agent-wdd/go.sum +++ b/agent-wdd/go.sum @@ -61,6 +61,8 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= @@ -69,10 +71,14 @@ golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/agent-wdd/log/SimpleLog.go b/agent-wdd/log/CallerLog.go similarity index 95% rename from agent-wdd/log/SimpleLog.go rename to agent-wdd/log/CallerLog.go index 6bb951c..5eefc6f 100644 --- a/agent-wdd/log/SimpleLog.go +++ b/agent-wdd/log/CallerLog.go @@ -42,8 +42,9 @@ func Error(format string, args ...interface{}) { func log(level string, color string, format string, args ...interface{}) { // 获取调用者信息(跳过2层调用栈) _, file, line, _ := runtime.Caller(2) + // fmt.Println("file is ", file) - s := strings.Split(file, "ProjectOctopus")[1] + s := strings.Split(file, "WddSuperAgent")[1] callerInfo := strings.TrimLeft(s, "/") + " " callerInfo += strconv.FormatInt(int64(line), 10) diff --git a/agent-wdd/utils/FileUtils.go b/agent-wdd/utils/FileUtils.go index 6d4c3ff..97cf99a 100644 --- a/agent-wdd/utils/FileUtils.go +++ b/agent-wdd/utils/FileUtils.go @@ -8,6 +8,7 @@ import ( "os" "os/user" "path/filepath" + "strconv" "strings" ) @@ -474,3 +475,28 @@ func GetCurrentUserFolder() string { return usr.HomeDir } + +// PermissionFile 设置文件权限 +func permissionFile(filePath string, permission string) error { + // 将字符串权限转换为 FileMode + perm, err := strconv.ParseUint(permission, 8, 32) + if err != nil { + return fmt.Errorf("权限格式无效: %w", err) + } + return os.Chmod(filePath, os.FileMode(perm)) +} + +// PermissionFileExecute 设置文件可执行权限 +func PermissionFileExecute(filePath string) error { + return permissionFile(filePath, "0777") +} + +// PermissionFileRead 设置文件可读权限 +func PermissionFileRead(filePath string) error { + return permissionFile(filePath, "0644") +} + +// PermissionFileWrite 设置文件可写权限 +func PermissionFileWrite(filePath string) error { + return permissionFile(filePath, "0666") +}