From 32cd10d06c988826edafd04b81505e66dbdf2fe1 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 8 Sep 2025 23:27:47 +0800 Subject: [PATCH] feat: support uploading certificates from local paths or urls --- internal/domain/workflow.go | 8 +- .../workflow/engine/executor_bizupload.go | 134 ++++++++++++- migrations/1756296000_cm0.4.0_migrate.go | 32 ++- .../forms/BizUploadNodeConfigForm.tsx | 188 +++++++++++++++--- .../designer/nodes/BizUploadNodeRegistry.tsx | 18 +- ui/src/domain/workflow.ts | 8 +- .../i18n/locales/en/nls.workflow.nodes.json | 22 +- .../i18n/locales/zh/nls.workflow.nodes.json | 22 +- 8 files changed, 377 insertions(+), 55 deletions(-) diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index f5b1f13b..5d13bcec 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -165,9 +165,9 @@ func (c WorkflowNodeConfig) AsBizApply() WorkflowNodeConfigForBizApply { func (c WorkflowNodeConfig) AsBizUpload() WorkflowNodeConfigForBizUpload { return WorkflowNodeConfigForBizUpload{ + Source: xmaps.GetOrDefaultString(c, "source", "form"), Certificate: xmaps.GetString(c, "certificate"), PrivateKey: xmaps.GetString(c, "privateKey"), - Domains: xmaps.GetString(c, "domains"), } } @@ -234,9 +234,9 @@ type WorkflowNodeConfigForBizApply struct { } type WorkflowNodeConfigForBizUpload struct { - Certificate string `json:"certificate"` // 证书 PEM 内容 - PrivateKey string `json:"privateKey"` // 私钥 PEM 内容 - Domains string `json:"domains,omitempty"` + Source string `json:"source"` // 证书来源(零值时默认值 "form") + Certificate string `json:"certificate"` // 证书,根据证书来源决定是 PEM 内容 / 文件路径 / URL + PrivateKey string `json:"privateKey"` // 私钥,根据证书来源决定是 PEM 内容 / 文件路径 / URL } type WorkflowNodeConfigForBizMonitor struct { diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go index c62cea79..f63510d5 100644 --- a/internal/workflow/engine/executor_bizupload.go +++ b/internal/workflow/engine/executor_bizupload.go @@ -1,13 +1,22 @@ package engine import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/tls" "fmt" "log/slog" + "os" "strings" "time" + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-resty/resty/v2" + "github.com/certimate-go/certimate/internal/domain" "github.com/certimate-go/certimate/internal/repository" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) /** @@ -26,6 +35,12 @@ type bizUploadNodeExecutor struct { wfoutputRepo workflowOutputRepository } +const ( + BizUploadSourceForm = "form" + BizUploadSourceLocal = "local" + BizUploadSourceURL = "url" +) + func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := newNodeExecutionResult(execCtx.Node) @@ -50,8 +65,102 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) - } else { + } else if lastCertificate != nil { ne.logger.Info("no found last uploaded certificate, begin to upload") + } else { + ne.logger.Info("try to upload") + } + + // 获取证书及私钥 + var certPEM, privkeyPEM string + switch nodeCfg.Source { + case BizUploadSourceForm: + { + certPEM = nodeCfg.Certificate + privkeyPEM = nodeCfg.PrivateKey + } + + case BizUploadSourceLocal: + { + certData, err := os.ReadFile(nodeCfg.Certificate) + if err != nil { + return execRes, fmt.Errorf("failed to read certificate file from local path: %w", err) + } else { + certPEM = string(certData) + } + + privkeyData, err := os.ReadFile(nodeCfg.PrivateKey) + if err != nil { + return execRes, fmt.Errorf("failed to read private key file from local path: %w", err) + } else { + privkeyPEM = string(privkeyData) + } + } + + case BizUploadSourceURL: + { + client := resty.New() + client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) + + certResp, err := client.NewRequest().Get(nodeCfg.Certificate) + if err != nil || certResp.IsError() { + return execRes, fmt.Errorf("failed to download certificate from URL: %w", err) + } else { + certPEM = string(certResp.Body()) + } + + privkeyResp, err := client.NewRequest().Get(nodeCfg.PrivateKey) + if err != nil || privkeyResp.IsError() { + return execRes, fmt.Errorf("failed to download private key from URL: %w", err) + } else { + privkeyPEM = string(privkeyResp.Body()) + } + } + + default: + return execRes, fmt.Errorf("unsupported upload source: '%s'", nodeCfg.Source) + } + + // 验证证书 + certX509, err := certcrypto.ParsePEMCertificate([]byte(certPEM)) + if err != nil { + return execRes, err + } else if time.Now().After(certX509.NotAfter) { + ne.logger.Warn(fmt.Sprintf("the uploaded certificate has expired at %s", certX509.NotAfter.UTC().Format(time.RFC3339))) + } + + // 验证私钥 + privkey, err := certcrypto.ParsePEMPrivateKey([]byte(privkeyPEM)) + if err != nil { + return nil, err + } else { + matched := false + switch pub := certX509.PublicKey.(type) { + case *rsa.PublicKey: + p, ok := privkey.(*rsa.PrivateKey) + matched = ok && pub.Equal(p.Public()) + case *ecdsa.PublicKey: + p, ok := privkey.(*ecdsa.PrivateKey) + matched = ok && pub.Equal(p.Public()) + case ed25519.PublicKey: + p, ok := privkey.(ed25519.PrivateKey) + matched = ok && pub.Equal(p.Public()) + default: + matched = false + } + + if !matched { + return nil, fmt.Errorf("the uploaded private key does not match the uploaded certificate") + } + } + + // 二次检测是否可以跳过执行 + if lastCertificate != nil { + lastCertX509, err := xcert.ParseCertificateFromPEM(lastCertificate.Certificate) + if err == nil && xcert.EqualCertificates(certX509, lastCertX509) { + ne.logger.Info("skip this uploading, because the last uploaded certificate already exists") + return execRes, nil + } } // 保存证书实体 @@ -61,7 +170,7 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx WorkflowRunId: execCtx.RunId, WorkflowNodeId: execCtx.Node.Id, } - certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey) + certificate.PopulateFromPEM(certPEM, privkeyPEM) if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil { ne.logger.Warn("could not save certificate") return execRes, err @@ -72,7 +181,7 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx // 节点输出 execRes.AddOutputWithPersistent(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, certificate.Id), "string") execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") - execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(certificate.ValidityNotAfter), "boolean") execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("uploading completed") @@ -104,11 +213,22 @@ func (ne *bizUploadNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, las // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 lastNodeCfg := lastOutput.NodeConfig.AsBizUpload() - if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) { - return false, "the configuration item 'Certificate' changed" + if thisNodeCfg.Source != lastNodeCfg.Source { + return false, "the configuration item 'Source' changed" } - if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) { - return false, "the configuration item 'PrivateKey' changed" + + switch thisNodeCfg.Source { + case BizUploadSourceForm: + if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) { + return false, "the configuration item 'Certificate' changed" + } + if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) { + return false, "the configuration item 'PrivateKey' changed" + } + + default: + // 本地或远程文件来源,需实际下载后才能比较 + return false, "" } } diff --git a/migrations/1756296000_cm0.4.0_migrate.go b/migrations/1756296000_cm0.4.0_migrate.go index 5e3500d9..5046d25c 100644 --- a/migrations/1756296000_cm0.4.0_migrate.go +++ b/migrations/1756296000_cm0.4.0_migrate.go @@ -1006,6 +1006,30 @@ func init() { }, }) + case "upload": + if _, ok := current.Config["source"].(string); !ok { + current.Config["source"] = "form" + } + + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "bizUpload", + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + + case "monitor": + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "bizMonitor", + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + case "deploy": if s, ok := current.Config["certificate"].(string); ok { current.Config["certificateOutputNodeId"] = strings.Split(s, "#")[0] @@ -1021,10 +1045,14 @@ func init() { }, }) - case "upload", "monitor", "notify": + case "notify": + if _, ok := current.Config["channel"].(string); ok { + delete(current.Config, "channel") + } + temp = append(temp, &dWorkflowNode{ Id: current.Id, - Type: "biz" + strings.Title(current.Type), + Type: "bizNotify", Data: dWorkflowNodeData{ Name: current.Name, Config: current.Config, diff --git a/ui/src/components/workflow/designer/forms/BizUploadNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizUploadNodeConfigForm.tsx index 3961f03c..f2d35e39 100644 --- a/ui/src/components/workflow/designer/forms/BizUploadNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizUploadNodeConfigForm.tsx @@ -1,12 +1,14 @@ import { useMemo } from "react"; import { getI18n, useTranslation } from "react-i18next"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; -import { type AnchorProps, Form, type FormInstance, Input } from "antd"; +import { type AnchorProps, Form, type FormInstance, Input, Radio } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import { validateCertificate, validatePrivateKey } from "@/api/certificates"; +import Show from "@/components/Show"; import TextFileInput from "@/components/TextFileInput"; +import Tips from "@/components/Tips"; import { type WorkflowNodeConfigForBizUpload, defaultNodeConfigForBizUpload } from "@/domain/workflow"; import { useAntdForm } from "@/hooks"; import { getErrMsg } from "@/utils/error"; @@ -19,6 +21,10 @@ export interface BizUploadNodeConfigFormProps { node: FlowNodeEntity; } +const UPLOAD_SOURCE_FORM = "form" as const; +const UPLOAD_SOURCE_LOCAL = "local" as const; +const UPLOAD_SOURCE_URL = "url" as const; + const BizUploadNodeConfigForm = ({ node, ...props }: BizUploadNodeConfigFormProps) => { if (node.flowNodeType !== NodeType.BizUpload) { console.warn(`[certimate] current workflow node type is not: ${NodeType.BizUpload}`); @@ -38,7 +44,21 @@ const BizUploadNodeConfigForm = ({ node, ...props }: BizUploadNodeConfigFormProp initialValues: initialValues ?? getInitialValues(), }); - const handleCertificateChange = async (value: string) => { + const fieldSource = Form.useWatch("source", { form: formInst, preserve: true }); + + const handleSourceChange = (value: string) => { + if (value === initialValues?.source) { + formInst.resetFields(["certificate", "privateKey", "domains"]); + } else { + setTimeout(() => { + formInst.setFieldValue("certificate", ""); + formInst.setFieldValue("privateKey", ""); + formInst.setFieldValue("domains", ""); + }, 0); + } + }; + + const handleCertificatePEMChange = async (value: string) => { try { const resp = await validateCertificate(value); formInst.setFields([ @@ -66,7 +86,7 @@ const BizUploadNodeConfigForm = ({ node, ...props }: BizUploadNodeConfigFormProp } }; - const handlePrivateKeyChange = async (value: string) => { + const handlePrivateKeyPEMChange = async (value: string) => { try { await validatePrivateKey(value); formInst.setFields([ @@ -90,25 +110,63 @@ const BizUploadNodeConfigForm = ({ node, ...props }: BizUploadNodeConfigFormProp
- - + + handleSourceChange(e.target.value)}> + {t("workflow_node.upload.form.source.option.form.label")} + {t("workflow_node.upload.form.source.option.local.label")} + {t("workflow_node.upload.form.source.option.url.label")} + - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -127,6 +185,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType>> => { return { + source: UPLOAD_SOURCE_FORM, certificate: "", privateKey: "", ...defaultNodeConfigForBizUpload(), @@ -136,17 +195,86 @@ const getInitialValues = (): Nullish>> => { const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { const { t } = i18n; - return z.object({ - domains: z.string().nullish(), - certificate: z - .string() - .min(1, t("workflow_node.upload.form.certificate.placeholder")) - .max(20480, t("common.errmsg.string_max", { max: 20480 })), - privateKey: z - .string() - .min(1, t("workflow_node.upload.form.private_key.placeholder")) - .max(20480, t("common.errmsg.string_max", { max: 20480 })), - }); + return z + .object({ + source: z.string(t("workflow_node.upload.form.source.placeholder")).nonempty(t("workflow_node.upload.form.source.placeholder")), + certificate: z.string().max(20480, t("common.errmsg.string_max", { max: 20480 })), + privateKey: z.string().max(20480, t("common.errmsg.string_max", { max: 20480 })), + domains: z.string().nullish(), + }) + .superRefine((values, ctx) => { + switch (values.source) { + case UPLOAD_SOURCE_FORM: + { + if (!z.string().nonempty().safeParse(values.certificate).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.certificate_pem.placeholder"), + path: ["certificate"], + }); + } + + if (!z.string().nonempty().safeParse(values.privateKey).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.private_key_pem.placeholder"), + path: ["privateKey"], + }); + } + } + break; + + case UPLOAD_SOURCE_LOCAL: + { + if (!z.string().nonempty().safeParse(values.certificate).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.certificate_path.placeholder"), + path: ["certificate"], + }); + } + + if (!z.string().nonempty().safeParse(values.privateKey).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.certificate_path.placeholder"), + path: ["privateKey"], + }); + } + } + break; + + case UPLOAD_SOURCE_URL: + { + if (!z.url().safeParse(values.certificate).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.certificate_url.placeholder"), + path: ["certificate"], + }); + } + + if (!z.url().safeParse(values.privateKey).success) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.private_key_url.placeholder"), + path: ["privateKey"], + }); + } + } + break; + + default: + { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.upload.form.source.placeholder"), + path: ["source"], + }); + } + break; + } + }); }; const _default = Object.assign(BizUploadNodeConfigForm, { diff --git a/ui/src/components/workflow/designer/nodes/BizUploadNodeRegistry.tsx b/ui/src/components/workflow/designer/nodes/BizUploadNodeRegistry.tsx index cd470683..d98476a6 100644 --- a/ui/src/components/workflow/designer/nodes/BizUploadNodeRegistry.tsx +++ b/ui/src/components/workflow/designer/nodes/BizUploadNodeRegistry.tsx @@ -43,7 +43,23 @@ export const BizUploadNodeRegistry: NodeRegistry = { return ( name="config.domains">{({ field: { value } }) => <>{value || t("workflow.detail.design.editor.placeholder")}}} + description={ + name="config.source"> + {({ field: { value: fieldSource } }) => ( + <> + {fieldSource == null || fieldSource === "" || fieldSource === "form" ? ( + name="config.domains"> + {({ field: { value: fieldDomains } }) => <>{fieldDomains || t("workflow.detail.design.editor.placeholder")}} + + ) : ( + name="config.certificate"> + {({ field: { value: fieldCertificate } }) => <>{fieldCertificate || t("workflow.detail.design.editor.placeholder")}} + + )} + + )} + + } /> ); }, diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 158ed402..8c4241a0 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -121,14 +121,16 @@ export const defaultNodeConfigForBizApply = (): Partial => { - return {}; + return { + source: "form" as const, + }; }; export type WorkflowNodeConfigForBizMonitor = { diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 0fbf3494..422a55d4 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -129,12 +129,26 @@ "workflow_node.upload.help": "Upload the user's existing SSL certificate.", "workflow_node.upload.default_name": "Uploading", "workflow_node.upload.form_anchor.parameters.tab": "Parameters", + "workflow_node.upload.form.guide": "The file content will be read again every time this node executes.", + "workflow_node.upload.form.source.label": "Upload source", + "workflow_node.upload.form.source.placeholder": "Please select upload source", + "workflow_node.upload.form.source.option.form.label": "Form", + "workflow_node.upload.form.source.option.local.label": "Local path", + "workflow_node.upload.form.source.option.url.label": "URL", "workflow_node.upload.form.domains.label": "Domains", "workflow_node.upload.form.domains.placholder": "Please select certificate file", - "workflow_node.upload.form.certificate.label": "Certificate (PEM format)", - "workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", - "workflow_node.upload.form.private_key.label": "Private key (PEM format)", - "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.certificate_pem.label": "Certificate (PEM format)", + "workflow_node.upload.form.certificate_pem.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "workflow_node.upload.form.certificate_path.label": "Certificate file path", + "workflow_node.upload.form.certificate_path.placeholder": "Please enter the local path for certificate file", + "workflow_node.upload.form.certificate_url.label": "Certificate file URL", + "workflow_node.upload.form.certificate_url.placeholder": "Please enter the URL for downloading certificate file", + "workflow_node.upload.form.private_key_pem.label": "Private key (PEM format)", + "workflow_node.upload.form.private_key_pem.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.private_key_path.label": "Private key file path", + "workflow_node.upload.form.private_key_path.placeholder": "Please enter the local path for private key file", + "workflow_node.upload.form.private_key_url.label": "Private key file URL", + "workflow_node.upload.form.private_key_url.placeholder": "Please enter the URL for downloading private key file", "workflow_node.monitor.label": "Monitor certificate", "workflow_node.monitor.help": "Obtain the SSL certificate of the website through HTTPS protocol.", "workflow_node.monitor.default_name": "Monitoring", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 3c921572..fd3b1bf5 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -128,12 +128,26 @@ "workflow_node.upload.help": "上传用户已有的本地 SSL 证书。", "workflow_node.upload.default_name": "上传", "workflow_node.upload.form_anchor.parameters.tab": "参数设置", + "workflow_node.upload.form.guide": "每次执行此节点时,都将重新读取文件内容。", + "workflow_node.upload.form.source.label": "上传来源", + "workflow_node.upload.form.source.placeholder": "请选择上传来源", + "workflow_node.upload.form.source.option.form.label": "表单", + "workflow_node.upload.form.source.option.local.label": "本地路径", + "workflow_node.upload.form.source.option.url.label": "URL 路径", "workflow_node.upload.form.domains.label": "域名", "workflow_node.upload.form.domains.placeholder": "上传证书文件后显示", - "workflow_node.upload.form.certificate.label": "证书文件(PEM 格式)", - "workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", - "workflow_node.upload.form.private_key.label": "私钥文件(PEM 格式)", - "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.certificate_pem.label": "证书文件(PEM 格式)", + "workflow_node.upload.form.certificate_pem.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "workflow_node.upload.form.certificate_path.label": "证书文件路径", + "workflow_node.upload.form.certificate_path.placeholder": "请输入证书文件本地路径", + "workflow_node.upload.form.certificate_url.label": "证书文件 URL", + "workflow_node.upload.form.certificate_url.placeholder": "请输入证书文件下载 URL", + "workflow_node.upload.form.private_key_pem.label": "私钥文件(PEM 格式)", + "workflow_node.upload.form.private_key_pem.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.private_key_path.label": "私钥文件路径", + "workflow_node.upload.form.private_key_path.placeholder": "请输入私钥文件本地路径", + "workflow_node.upload.form.private_key_url.label": "私钥文件 URL", + "workflow_node.upload.form.private_key_url.placeholder": "请输入私钥文件下载 URL", "workflow_node.monitor.label": "监控网站证书", "workflow_node.monitor.help": "通过 HTTPS 协议获取指定网站的 SSL 证书。", "workflow_node.monitor.default_name": "监控",