diff --git a/backend/internal/cert/deploy/plugin/plugin.go b/backend/internal/cert/deploy/plugin/plugin.go index 76c3327..2281c48 100644 --- a/backend/internal/cert/deploy/plugin/plugin.go +++ b/backend/internal/cert/deploy/plugin/plugin.go @@ -25,12 +25,13 @@ type ActionInfo struct { } type PluginMetadata struct { - Name string `json:"name"` - Description string `json:"description"` - Version string `json:"version"` - Author string `json:"author"` - Actions []ActionInfo `json:"actions"` - Path string // 插件路径 + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` + Actions []ActionInfo `json:"actions"` + Config map[string]any `json:"config,omitempty"` // 可选配置 + Path string // 插件路径 } type Request struct { diff --git a/plugins/aliyun.exe b/plugins/aliyun.exe new file mode 100644 index 0000000..ed521e0 Binary files /dev/null and b/plugins/aliyun.exe differ diff --git a/plugins/aliyun/action.go b/plugins/aliyun/action.go new file mode 100644 index 0000000..c759126 --- /dev/null +++ b/plugins/aliyun/action.go @@ -0,0 +1,156 @@ +package main + +import ( + "ALLinSSL/plugins/aliyun/cas" + "ALLinSSL/plugins/aliyun/esa" + "fmt" + "strconv" + "strings" +) + +func uploadToCAS(cfg map[string]any) (*Response, error) { + if cfg == nil { + return nil, fmt.Errorf("config cannot be nil") + } + certStr, ok := cfg["cert"].(string) + if !ok || certStr == "" { + return nil, fmt.Errorf("cert is required and must be a string") + } + keyStr, ok := cfg["key"].(string) + if !ok || keyStr == "" { + return nil, fmt.Errorf("key is required and must be a string") + } + accessKey, ok := cfg["access_key"].(string) + if !ok || accessKey == "" { + return nil, fmt.Errorf("access_key is required and must be a string") + } + secretKey, ok := cfg["secret_key"].(string) + if !ok || secretKey == "" { + return nil, fmt.Errorf("secret_key is required and must be a string") + } + endpoint, ok := cfg["endpoint"].(string) + if !ok || endpoint == "" { + endpoint = "cas.ap-southeast-1.aliyuncs.com" // 默认值 + } + name, ok := cfg["name"].(string) + if !ok || name == "" { + name = "allinssl-certificate" // 默认名称 + } + + client, err := cas.CreateClient(accessKey, secretKey, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to create CAS client: %w", err) + } + // 上传证书到 CAS + err = cas.UploadToCas(client, certStr, keyStr, name) + if err != nil { + return nil, fmt.Errorf("failed to upload certificate to CAS: %w", err) + } + + return &Response{ + Status: "success", + Message: "CAS upload successful", + Result: nil, + }, nil +} + +func deployToESA(cfg map[string]any) (*Response, error) { + if cfg == nil { + return nil, fmt.Errorf("config cannot be nil") + } + certPEM, ok := cfg["cert"].(string) + if !ok || certPEM == "" { + return nil, fmt.Errorf("cert is required and must be a string") + } + privkeyPEM, ok := cfg["key"].(string) + if !ok || privkeyPEM == "" { + return nil, fmt.Errorf("key is required and must be a string") + } + accessKey, ok := cfg["access_key"].(string) + if !ok || accessKey == "" { + return nil, fmt.Errorf("access_key is required and must be a string") + } + secretKey, ok := cfg["secret_key"].(string) + if !ok || secretKey == "" { + return nil, fmt.Errorf("secret_key is required and must be a string") + } + var siteID int64 + switch v := cfg["site_id"].(type) { + case float64: + siteID = int64(v) + case string: + var err error + siteID, err = strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("site_id format error: %w", err) + } + case int: + siteID = int64(v) + default: + return nil, fmt.Errorf("site_id format error") + } + var delRepeatDomainCert bool + switch v := cfg["del_repeat_domain_cert"].(type) { + case bool: + delRepeatDomainCert = v + case string: + if v == "true" { + delRepeatDomainCert = true + } + case nil: + delRepeatDomainCert = false + } + + client, err := esa.CreateEsaClient(accessKey, secretKey) + if err != nil { + return nil, fmt.Errorf("failed to create ESA client: %w", err) + } + + // 检查是否需要删除重复的域名证书 + if delRepeatDomainCert { + // 解析现有证书的域名 + certObj, err := ParseCertificate([]byte(certPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + domainSet := make(map[string]bool) + + if certObj.Subject.CommonName != "" { + domainSet[certObj.Subject.CommonName] = true + } + for _, dns := range certObj.DNSNames { + domainSet[dns] = true + } + + // 转成切片并拼接成逗号分隔的字符串 + var domains []string + for domain := range domainSet { + domains = append(domains, domain) + } + domainList := strings.Join(domains, ",") + + certList, err := esa.ListCertFromESA(client, siteID) + if err != nil { + return nil, fmt.Errorf("failed to list certificates from ESA: %w", err) + } + for _, cert := range certList { + if *cert.SAN == domainList { + err = esa.DeleteEsaCert(client, siteID, *cert.Id) + if err != nil { + return nil, fmt.Errorf("failed to delete existing certificate: %w", err) + } + } + } + } + + err = esa.UploadCertToESA(client, siteID, certPEM, privkeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to upload certificate to ESA: %w", err) + } + + return &Response{ + Status: "success", + Message: "ESA deployment successful", + Result: nil, + }, nil +} diff --git a/plugins/aliyun/cas/action.go b/plugins/aliyun/cas/action.go new file mode 100644 index 0000000..97e1dd4 --- /dev/null +++ b/plugins/aliyun/cas/action.go @@ -0,0 +1,31 @@ +package cas + +import ( + cas "github.com/alibabacloud-go/cas-20200407/v4/client" + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" +) + +func CreateClient(accessKey, accessSecret, endpoint string) (*cas.Client, error) { + if endpoint == "" { + endpoint = "cas.ap-southeast-1.aliyuncs.com" + } + config := &openapi.Config{ + AccessKeyId: tea.String(accessKey), + AccessKeySecret: tea.String(accessSecret), + Endpoint: tea.String(endpoint), + } + return cas.NewClient(config) +} + +func UploadToCas(client *cas.Client, cert, key, name string) error { + uploadUserCertificateRequest := &cas.UploadUserCertificateRequest{ + Name: tea.String(name), + Cert: tea.String(cert), + Key: tea.String(key), + } + runtime := &util.RuntimeOptions{} + _, err := client.UploadUserCertificateWithOptions(uploadUserCertificateRequest, runtime) + return err +} diff --git a/plugins/aliyun/esa/action.go b/plugins/aliyun/esa/action.go new file mode 100644 index 0000000..319821e --- /dev/null +++ b/plugins/aliyun/esa/action.go @@ -0,0 +1,63 @@ +package esa + +import ( + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + esa "github.com/alibabacloud-go/esa-20240910/v2/client" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" +) + +// CreateEsaClient creates a new ESA client with the provided access key and secret. +func CreateEsaClient(accessKey, accessSecret string) (*esa.Client, error) { + config := &openapi.Config{ + AccessKeyId: tea.String(accessKey), + AccessKeySecret: tea.String(accessSecret), + Endpoint: tea.String("esa.ap-southeast-1.aliyuncs.com"), + } + return esa.NewClient(config) +} + +// UploadCertToESA uploads the certificate and private key to Alibaba Cloud ESA. +func UploadCertToESA(client *esa.Client, id int64, certPEM, privkeyPEM string) error { + req := esa.SetCertificateRequest{ + SiteId: tea.Int64(id), + Type: tea.String("upload"), + Certificate: tea.String(certPEM), + PrivateKey: tea.String(privkeyPEM), + } + runtime := &util.RuntimeOptions{} + + _, err := client.SetCertificateWithOptions(&req, runtime) + if err != nil { + return err + } + return nil +} + +// ListCertFromESA retrieves the list of certificates from Alibaba Cloud ESA for a given site ID. +func ListCertFromESA(client *esa.Client, id int64) ([]*esa.ListCertificatesResponseBodyResult, error) { + req := esa.ListCertificatesRequest{ + SiteId: tea.Int64(id), + } + runtime := &util.RuntimeOptions{} + resp, err := client.ListCertificatesWithOptions(&req, runtime) + if err != nil { + return nil, err + } + return resp.Body.Result, nil +} + +// DeleteEsaCert deletes a certificate from Alibaba Cloud ESA by its ID. +func DeleteEsaCert(client *esa.Client, id int64, certID string) error { + req := esa.DeleteCertificateRequest{ + SiteId: tea.Int64(id), + Id: tea.String(certID), + } + runtime := &util.RuntimeOptions{} + + _, err := client.DeleteCertificateWithOptions(&req, runtime) + if err != nil { + return err + } + return nil +} diff --git a/plugins/aliyun/main.go b/plugins/aliyun/main.go new file mode 100644 index 0000000..e3b3c94 --- /dev/null +++ b/plugins/aliyun/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "os" +) + +type ActionInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Params map[string]any `json:"params,omitempty"` // 可选参数 +} + +type Request struct { + Action string `json:"action"` + Params map[string]interface{} `json:"params"` +} + +type Response struct { + Status string `json:"status"` + Message string `json:"message"` + Result map[string]interface{} `json:"result"` +} + +var pluginMeta = map[string]interface{}{ + "name": "aliyun", + "description": "部署到阿里云", + "version": "1.0.0", + "author": "主包", + "config": map[string]interface{}{ + "access_key": "阿里云 AccessKey", + "secret_key": "阿里云 SecretKey", + }, + "actions": []ActionInfo{ + { + Name: "deployToESA", + Description: "部署到阿里云esa", + Params: map[string]any{ + "site_id": "站点 ID", + "del_repeat_domain_cert": "是否删除重复的域名证书,默认 false", + }, + }, + { + Name: "uploadToCAS", + Description: "上传到阿里云cas", + Params: map[string]any{ + "name": "证书名称", + }, + }, + }, +} + +// **解析 PEM 格式的证书** +func ParseCertificate(certPEM []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, fmt.Errorf("无法解析证书 PEM") + } + return x509.ParseCertificate(block.Bytes) +} + +func outputJSON(resp *Response) { + _ = json.NewEncoder(os.Stdout).Encode(resp) +} + +func outputError(msg string, err error) { + outputJSON(&Response{ + Status: "error", + Message: fmt.Sprintf("%s: %v", msg, err), + }) +} + +func main() { + var req Request + input, err := io.ReadAll(os.Stdin) + if err != nil { + outputError("读取输入失败", err) + return + } + + if err := json.Unmarshal(input, &req); err != nil { + outputError("解析请求失败", err) + return + } + + switch req.Action { + case "get_metadata": + outputJSON(&Response{ + Status: "success", + Message: "插件信息", + Result: pluginMeta, + }) + case "list_actions": + outputJSON(&Response{ + Status: "success", + Message: "支持的动作", + Result: map[string]interface{}{"actions": pluginMeta["actions"]}, + }) + case "deployToESA": + rep, err := deployToESA(req.Params) + if err != nil { + outputError("ESA 部署失败", err) + return + } + outputJSON(rep) + case "uploadToCAS": + rep, err := uploadToCAS(req.Params) + if err != nil { + outputError("CAS 上传失败", err) + return + } + outputJSON(rep) + default: + outputJSON(&Response{ + Status: "error", + Message: "未知 action: " + req.Action, + }) + } +}