feat: support uploading certificates from local paths or urls

This commit is contained in:
Fu Diwei 2025-09-08 23:27:47 +08:00
parent cfe3f6cc01
commit 32cd10d06c
8 changed files with 377 additions and 55 deletions

View File

@ -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 {

View File

@ -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, ""
}
}

View File

@ -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,

View File

@ -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
<NodeFormContextProvider value={{ node }}>
<Form {...formProps} clearOnDestroy={true} form={formInst} layout="vertical" preserve={false} scrollToFirstError>
<div id="parameters" data-anchor="parameters">
<Form.Item name="domains" label={t("workflow_node.upload.form.domains.label")} rules={[formRule]}>
<Input variant="filled" placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
<Form.Item name="source" label={t("workflow_node.upload.form.source.label")} rules={[formRule]}>
<Radio.Group block onChange={(e) => handleSourceChange(e.target.value)}>
<Radio.Button value={UPLOAD_SOURCE_FORM}>{t("workflow_node.upload.form.source.option.form.label")}</Radio.Button>
<Radio.Button value={UPLOAD_SOURCE_LOCAL}>{t("workflow_node.upload.form.source.option.local.label")}</Radio.Button>
<Radio.Button value={UPLOAD_SOURCE_URL}>{t("workflow_node.upload.form.source.option.url.label")}</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate.label")} rules={[formRule]}>
<TextFileInput
autoSize={{ minRows: 3, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.certificate.placeholder")}
onChange={handleCertificateChange}
/>
</Form.Item>
<Show when={fieldSource === UPLOAD_SOURCE_FORM}>
<Form.Item name="domains" label={t("workflow_node.upload.form.domains.label")} rules={[formRule]}>
<Input variant="filled" placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key.label")} rules={[formRule]}>
<TextFileInput
autoSize={{ minRows: 3, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.private_key.placeholder")}
onChange={handlePrivateKeyChange}
/>
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate_pem.label")} rules={[formRule]}>
<TextFileInput
autoSize={{ minRows: 3, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.certificate_pem.placeholder")}
onChange={handleCertificatePEMChange}
/>
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key_pem.label")} rules={[formRule]}>
<TextFileInput
autoSize={{ minRows: 3, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.private_key_pem.placeholder")}
onChange={handlePrivateKeyPEMChange}
/>
</Form.Item>
</Show>
<Show when={fieldSource === UPLOAD_SOURCE_LOCAL}>
<Form.Item>
<Tips message={t("workflow_node.upload.form.guide")} />
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate_path.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.certificate_path.placeholder")} />
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key_path.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.private_key_path.placeholder")} />
</Form.Item>
</Show>
<Show when={fieldSource === UPLOAD_SOURCE_URL}>
<Form.Item>
<Tips message={t("workflow_node.upload.form.guide")} />
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate_url.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.certificate_url.placeholder")} />
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key_url.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.private_key_url.placeholder")} />
</Form.Item>
</Show>
</div>
</Form>
</NodeFormContextProvider>
@ -127,6 +185,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
return {
source: UPLOAD_SOURCE_FORM,
certificate: "",
privateKey: "",
...defaultNodeConfigForBizUpload(),
@ -136,17 +195,86 @@ const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }) => {
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, {

View File

@ -43,7 +43,23 @@ export const BizUploadNodeRegistry: NodeRegistry = {
return (
<BaseNode
description={<Field<string> name="config.domains">{({ field: { value } }) => <>{value || t("workflow.detail.design.editor.placeholder")}</>}</Field>}
description={
<Field<string> name="config.source">
{({ field: { value: fieldSource } }) => (
<>
{fieldSource == null || fieldSource === "" || fieldSource === "form" ? (
<Field<string> name="config.domains">
{({ field: { value: fieldDomains } }) => <>{fieldDomains || t("workflow.detail.design.editor.placeholder")}</>}
</Field>
) : (
<Field<string> name="config.certificate">
{({ field: { value: fieldCertificate } }) => <>{fieldCertificate || t("workflow.detail.design.editor.placeholder")}</>}
</Field>
)}
</>
)}
</Field>
}
/>
);
},

View File

@ -121,14 +121,16 @@ export const defaultNodeConfigForBizApply = (): Partial<WorkflowNodeConfigForBiz
};
export type WorkflowNodeConfigForBizUpload = {
certificateId: string;
domains: string;
source: string;
domains?: string;
certificate: string;
privateKey: string;
};
export const defaultNodeConfigForBizUpload = (): Partial<WorkflowNodeConfigForBizUpload> => {
return {};
return {
source: "form" as const,
};
};
export type WorkflowNodeConfigForBizMonitor = {

View File

@ -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",

View File

@ -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": "监控",