添加SSL证书管理功能,包括安装、续期、列出、撤销和申请证书的命令,同时更新依赖项和修复磁盘使用情况计算逻辑。
This commit is contained in:
16
.cursor/rules/agent-wdd.mdc
Normal file
16
.cursor/rules/agent-wdd.mdc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 你是一个精通cobra的大师,熟知现代化CLI工具的开发,能够实现架构健壮的CLI程序
|
||||||
|
- 能够设计出功能完善,健壮的交互式CLI
|
||||||
|
|
||||||
|
# 项目代码风格要求
|
||||||
|
- 函数的参数注释,返回注释,功能注释
|
||||||
|
- 在代码编写过程中,使用 [CallerLog.go](mdc:agent-wdd/log/CallerLog.go) 多打印日志
|
||||||
|
- 代码中也有详细的流程说明,功能说明
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
35
.cursor/rules/cert-manager-wdd.mdc
Normal file
35
.cursor/rules/cert-manager-wdd.mdc
Normal file
@@ -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申请新的证书
|
||||||
144
.cursor/rules/cloudflare.mdc
Normal file
144
.cursor/rules/cloudflare.mdc
Normal file
@@ -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记录
|
||||||
104
.idea/workspace.xml
generated
Normal file
104
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="a8fa4a21-4f18-44c5-b1f8-06c0b196f277" name="Changes" comment="增加run文件" />
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="GOROOT" url="file://$PROJECT_DIR$/../../../../../Program Files/Go" />
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="KubernetesApiPersistence"><![CDATA[{}]]></component>
|
||||||
|
<component name="KubernetesApiProvider"><![CDATA[{
|
||||||
|
"isMigrated": true
|
||||||
|
}]]></component>
|
||||||
|
<component name="MaliciousPackageCheckinHandlerState">
|
||||||
|
<option name="packageCheck" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo"><![CDATA[{
|
||||||
|
"associatedIndex": 3
|
||||||
|
}]]></component>
|
||||||
|
<component name="ProjectId" id="2utKw4naSvx8QWsUOLbV0bV13oh" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
|
"keyToString": {
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
||||||
|
"RunOnceActivity.go.migrated.go.modules.settings": "true",
|
||||||
|
"git-widget-placeholder": "main",
|
||||||
|
"go.import.settings.migrated": "true",
|
||||||
|
"go.sdk.automatically.set": "true",
|
||||||
|
"last_opened_file_path": "C:/Users/wddsh/Documents/IdeaProjects/WddSuperAgent",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.detected.package.tslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
|
}
|
||||||
|
}]]></component>
|
||||||
|
<component name="SharedIndexes">
|
||||||
|
<attachedChunks>
|
||||||
|
<set>
|
||||||
|
<option value="bundled-jdk-9823dce3aa75-a94e463ab2e7-intellij.indexing.shared.core-IU-243.26053.27" />
|
||||||
|
<option value="bundled-js-predefined-d6986cc7102b-1632447f56bf-JavaScript-IU-243.26053.27" />
|
||||||
|
</set>
|
||||||
|
</attachedChunks>
|
||||||
|
</component>
|
||||||
|
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="a8fa4a21-4f18-44c5-b1f8-06c0b196f277" name="Changes" comment="" />
|
||||||
|
<created>1743062665598</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1743062665598</updated>
|
||||||
|
<workItem from="1743062666757" duration="1644000" />
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00001" summary="初始化项目">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1743062965296</created>
|
||||||
|
<option name="number" value="00001" />
|
||||||
|
<option name="presentableId" value="LOCAL-00001" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1743062965296</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00002" summary="增加run文件">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1743063154455</created>
|
||||||
|
<option name="number" value="00002" />
|
||||||
|
<option name="presentableId" value="LOCAL-00002" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1743063154455</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="3" />
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="VcsManagerConfiguration">
|
||||||
|
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||||
|
<option name="CHECK_CODE_CLEANUP_BEFORE_PROJECT_COMMIT" value="true" />
|
||||||
|
<option name="CHECK_NEW_TODO" value="false" />
|
||||||
|
<MESSAGE value="初始化项目" />
|
||||||
|
<MESSAGE value="增加run文件" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="增加run文件" />
|
||||||
|
<option name="OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT" value="true" />
|
||||||
|
<option name="REFORMAT_BEFORE_PROJECT_COMMIT" value="true" />
|
||||||
|
<option name="REARRANGE_BEFORE_PROJECT_COMMIT" value="true" />
|
||||||
|
<option name="NON_MODAL_COMMIT_POSTPONE_SLOW_CHECKS" value="false" />
|
||||||
|
</component>
|
||||||
|
<component name="VgoProject">
|
||||||
|
<settings-migrated>true</settings-migrated>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
374
agent-wdd/cert_manager_wdd/AcmeClient.go
Normal file
374
agent-wdd/cert_manager_wdd/AcmeClient.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
321
agent-wdd/cert_manager_wdd/CertMan.go
Normal file
321
agent-wdd/cert_manager_wdd/CertMan.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
187
agent-wdd/cert_manager_wdd/DNSSolver.go
Normal file
187
agent-wdd/cert_manager_wdd/DNSSolver.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
153
agent-wdd/cloudflare/Cloudflare.go
Normal file
153
agent-wdd/cloudflare/Cloudflare.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
310
agent-wdd/cloudflare/Cloudflare_test.go
Normal file
310
agent-wdd/cloudflare/Cloudflare_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
304
agent-wdd/cloudflare/DNS.go
Normal file
304
agent-wdd/cloudflare/DNS.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
218
agent-wdd/cloudflare/Zone.go
Normal file
218
agent-wdd/cloudflare/Zone.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,10 +1,260 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"agent-wdd/log"
|
||||||
|
"agent-wdd/op"
|
||||||
|
"agent-wdd/utils"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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的相关任务
|
// addAcmeSubcommands acme的相关任务
|
||||||
func addAcmeSubcommands(cmd *cobra.Command) {
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
258
agent-wdd/cmd/CertManager.go
Normal file
258
agent-wdd/cmd/CertManager.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -104,6 +104,13 @@ func Execute() {
|
|||||||
|
|
||||||
addDownloadSubcommands(downloadCmd)
|
addDownloadSubcommands(downloadCmd)
|
||||||
|
|
||||||
|
// 11. cert命令
|
||||||
|
certCmd := &cobra.Command{
|
||||||
|
Use: "cert",
|
||||||
|
Short: "SSL证书管理",
|
||||||
|
}
|
||||||
|
addCertManagerSubcommands(certCmd)
|
||||||
|
|
||||||
helpCmd := &cobra.Command{
|
helpCmd := &cobra.Command{
|
||||||
Use: "help",
|
Use: "help",
|
||||||
Short: "帮助信息",
|
Short: "帮助信息",
|
||||||
@@ -131,6 +138,7 @@ func Execute() {
|
|||||||
versionCmd,
|
versionCmd,
|
||||||
configCmd,
|
configCmd,
|
||||||
downloadCmd,
|
downloadCmd,
|
||||||
|
certCmd,
|
||||||
helpCmd,
|
helpCmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"agent-wdd/log"
|
"agent-wdd/log"
|
||||||
"agent-wdd/utils"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var CommonDiskPath = []string{
|
var CommonDiskPath = []string{
|
||||||
@@ -81,32 +78,33 @@ func DiskListGather() {
|
|||||||
//utils.BeautifulPrint(diskList)
|
//utils.BeautifulPrint(diskList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算磁盘使用情况
|
||||||
func (disk *Disk) calculateDiskUsage() {
|
func (disk *Disk) calculateDiskUsage() {
|
||||||
var stat syscall.Statfs_t
|
// var stat syscall.Statfs_t
|
||||||
err := syscall.Statfs(disk.Path, &stat)
|
// err := syscall.Statfs(disk.Path, &stat)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
log.Error("disk syscall error: %v", err)
|
// log.Error("disk syscall error: %v", err)
|
||||||
disk.Size = "0B"
|
// disk.Size = "0B"
|
||||||
disk.Usage = "0B"
|
// disk.Usage = "0B"
|
||||||
disk.Percent = "0.00%"
|
// disk.Percent = "0.00%"
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 计算存储空间大小
|
// // 计算存储空间大小
|
||||||
totalBytes := stat.Blocks * uint64(stat.Bsize)
|
// totalBytes := stat.Blocks * uint64(stat.Bsize)
|
||||||
availBytes := stat.Bavail * uint64(stat.Bsize)
|
// availBytes := stat.Bavail * uint64(stat.Bsize)
|
||||||
usedBytes := totalBytes - availBytes
|
// usedBytes := totalBytes - availBytes
|
||||||
|
|
||||||
// 格式化输出
|
// // 格式化输出
|
||||||
disk.Size = utils.HumanDiskSize(totalBytes)
|
// disk.Size = utils.HumanDiskSize(totalBytes)
|
||||||
disk.Usage = utils.HumanDiskSize(usedBytes)
|
// disk.Usage = utils.HumanDiskSize(usedBytes)
|
||||||
|
|
||||||
if totalBytes == 0 {
|
// if totalBytes == 0 {
|
||||||
disk.Percent = "0.00%"
|
// disk.Percent = "0.00%"
|
||||||
} else {
|
// } else {
|
||||||
percent := float64(usedBytes) / float64(totalBytes) * 100
|
// percent := float64(usedBytes) / float64(totalBytes) * 100
|
||||||
disk.Percent = fmt.Sprintf("%.2f%%", percent)
|
// disk.Percent = fmt.Sprintf("%.2f%%", percent)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
func DiskListSaveConfig() {
|
func DiskListSaveConfig() {
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr 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/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
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 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ func Error(format string, args ...interface{}) {
|
|||||||
func log(level string, color string, format string, args ...interface{}) {
|
func log(level string, color string, format string, args ...interface{}) {
|
||||||
// 获取调用者信息(跳过2层调用栈)
|
// 获取调用者信息(跳过2层调用栈)
|
||||||
_, file, line, _ := runtime.Caller(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 := strings.TrimLeft(s, "/") + " "
|
||||||
callerInfo += strconv.FormatInt(int64(line), 10)
|
callerInfo += strconv.FormatInt(int64(line), 10)
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -474,3 +475,28 @@ func GetCurrentUserFolder() string {
|
|||||||
|
|
||||||
return usr.HomeDir
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user