From 993ca36755ad8af5a4a6181d252beb9071882314 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Wed, 21 May 2025 13:48:54 +0800 Subject: [PATCH] add certificate mornitoring node --- internal/domain/workflow.go | 23 +++ .../workflow/node-processor/inspect_node.go | 159 ++++++++++++++++++ .../node-processor/inspect_node_test.go | 39 +++++ internal/workflow/node-processor/processor.go | 2 + .../components/workflow/WorkflowElement.tsx | 4 + ui/src/components/workflow/node/AddNode.tsx | 2 + .../workflow/node/ConditionNode.tsx | 1 - .../workflow/node/ConditionNodeConfigForm.tsx | 1 - .../components/workflow/node/InspectNode.tsx | 90 ++++++++++ .../workflow/node/InspectNodeConfigForm.tsx | 85 ++++++++++ ui/src/domain/workflow.ts | 20 ++- .../i18n/locales/en/nls.workflow.nodes.json | 7 +- .../i18n/locales/zh/nls.workflow.nodes.json | 7 +- 13 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 internal/workflow/node-processor/inspect_node.go create mode 100644 internal/workflow/node-processor/inspect_node_test.go create mode 100644 ui/src/components/workflow/node/InspectNode.tsx create mode 100644 ui/src/components/workflow/node/InspectNodeConfigForm.tsx diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 63237221..c1d2db86 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -38,6 +38,7 @@ const ( WorkflowNodeTypeExecuteResultBranch = WorkflowNodeType("execute_result_branch") WorkflowNodeTypeExecuteSuccess = WorkflowNodeType("execute_success") WorkflowNodeTypeExecuteFailure = WorkflowNodeType("execute_failure") + WorkflowNodeTypeInspect = WorkflowNodeType("inspect") ) type WorkflowTriggerType string @@ -86,6 +87,11 @@ type WorkflowNodeConfigForCondition struct { Expression Expr `json:"expression"` // 条件表达式 } +type WorkflowNodeConfigForInspect struct { + Domain string `json:"domain"` // 域名 + Port string `json:"port"` // 端口 +} + type WorkflowNodeConfigForUpload struct { Certificate string `json:"certificate"` PrivateKey string `json:"privateKey"` @@ -127,6 +133,23 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { } } +func (n *WorkflowNode) GetConfigForInspect() WorkflowNodeConfigForInspect { + domain := maputil.GetString(n.Config, "domain") + if domain == "" { + return WorkflowNodeConfigForInspect{} + } + + port := maputil.GetString(n.Config, "port") + if port == "" { + port = "443" + } + + return WorkflowNodeConfigForInspect{ + Domain: domain, + Port: port, + } +} + func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { skipBeforeExpiryDays := maputil.GetInt32(n.Config, "skipBeforeExpiryDays") if skipBeforeExpiryDays == 0 { diff --git a/internal/workflow/node-processor/inspect_node.go b/internal/workflow/node-processor/inspect_node.go new file mode 100644 index 00000000..6c6bea6a --- /dev/null +++ b/internal/workflow/node-processor/inspect_node.go @@ -0,0 +1,159 @@ +package nodeprocessor + +import ( + "context" + "crypto/tls" + "fmt" + "math" + "net" + "time" + + "github.com/usual2970/certimate/internal/domain" +) + +type inspectNode struct { + node *domain.WorkflowNode + *nodeProcessor + *nodeOutputer +} + +func NewInspectNode(node *domain.WorkflowNode) *inspectNode { + return &inspectNode{ + node: node, + nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), + } +} + +func (n *inspectNode) Process(ctx context.Context) error { + n.logger.Info("enter inspect website certificate node ...") + + nodeConfig := n.node.GetConfigForInspect() + + err := n.inspect(ctx, nodeConfig) + if err != nil { + n.logger.Warn("inspect website certificate failed: " + err.Error()) + return err + } + + return nil +} + +func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNodeConfigForInspect) error { + // 定义重试参数 + maxRetries := 3 + retryInterval := 2 * time.Second + + var cert *tls.Certificate + var lastError error + + domainWithPort := nodeConfig.Domain + ":" + nodeConfig.Port + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + n.logger.Info(fmt.Sprintf("Retry #%d connecting to %s", attempt, domainWithPort)) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + // Wait for retry interval + } + } + + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + } + + conn, err := tls.DialWithDialer(dialer, "tcp", domainWithPort, &tls.Config{ + InsecureSkipVerify: true, // Allow self-signed certificates + }) + if err != nil { + lastError = fmt.Errorf("failed to connect to %s: %w", domainWithPort, err) + n.logger.Warn(fmt.Sprintf("Connection attempt #%d failed: %s", attempt+1, lastError.Error())) + continue + } + + // Get certificate information + certInfo := conn.ConnectionState().PeerCertificates[0] + conn.Close() + + // Certificate information retrieved successfully + cert = &tls.Certificate{ + Certificate: [][]byte{certInfo.Raw}, + Leaf: certInfo, + } + lastError = nil + n.logger.Info(fmt.Sprintf("Successfully retrieved certificate information for %s", domainWithPort)) + break + } + + if lastError != nil { + return fmt.Errorf("failed to retrieve certificate after %d attempts: %w", maxRetries, lastError) + } + + certInfo := cert.Leaf + now := time.Now() + + isValid := now.Before(certInfo.NotAfter) && now.After(certInfo.NotBefore) + + // Check domain matching + domainMatch := false + if len(certInfo.DNSNames) > 0 { + for _, dnsName := range certInfo.DNSNames { + if matchDomain(nodeConfig.Domain, dnsName) { + domainMatch = true + break + } + } + } else if matchDomain(nodeConfig.Domain, certInfo.Subject.CommonName) { + domainMatch = true + } + + isValid = isValid && domainMatch + + daysRemaining := math.Floor(certInfo.NotAfter.Sub(now).Hours() / 24) + + // Set node outputs + outputs := map[string]any{ + "certificate.validated": isValid, + "certificate.daysLeft": daysRemaining, + } + n.setOutputs(outputs) + + return nil +} + +func (n *inspectNode) setOutputs(outputs map[string]any) { + n.outputs = outputs +} + +func matchDomain(requestDomain, certDomain string) bool { + if requestDomain == certDomain { + return true + } + + if len(certDomain) > 2 && certDomain[0] == '*' && certDomain[1] == '.' { + + wildcardSuffix := certDomain[1:] + requestDomainLen := len(requestDomain) + suffixLen := len(wildcardSuffix) + + if requestDomainLen > suffixLen && requestDomain[requestDomainLen-suffixLen:] == wildcardSuffix { + remainingPart := requestDomain[:requestDomainLen-suffixLen] + if len(remainingPart) > 0 && !contains(remainingPart, '.') { + return true + } + } + } + + return false +} + +func contains(s string, c byte) bool { + for i := 0; i < len(s); i++ { + if s[i] == c { + return true + } + } + return false +} diff --git a/internal/workflow/node-processor/inspect_node_test.go b/internal/workflow/node-processor/inspect_node_test.go new file mode 100644 index 00000000..5cb826c1 --- /dev/null +++ b/internal/workflow/node-processor/inspect_node_test.go @@ -0,0 +1,39 @@ +package nodeprocessor + +import ( + "context" + "testing" + + "github.com/usual2970/certimate/internal/domain" +) + +func Test_inspectWebsiteCertificateNode_inspect(t *testing.T) { + type args struct { + ctx context.Context + nodeConfig domain.WorkflowNodeConfigForInspect + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "test1", + args: args{ + ctx: context.Background(), + nodeConfig: domain.WorkflowNodeConfigForInspect{ + Domain: "baidu.com", + Port: "443", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := NewInspectNode(&domain.WorkflowNode{}) + if err := n.inspect(tt.args.ctx, tt.args.nodeConfig); (err != nil) != tt.wantErr { + t.Errorf("inspectWebsiteCertificateNode.inspect() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index eb7bc155..4cdfe76f 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -86,6 +86,8 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { return NewExecuteSuccessNode(node), nil case domain.WorkflowNodeTypeExecuteFailure: return NewExecuteFailureNode(node), nil + case domain.WorkflowNodeTypeInspect: + return NewInspectNode(node), nil } return nil, fmt.Errorf("supported node type: %s", string(node.Type)) diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index 3aa70ff3..d36029df 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -12,6 +12,7 @@ import ExecuteResultNode from "./node/ExecuteResultNode"; import NotifyNode from "./node/NotifyNode"; import StartNode from "./node/StartNode"; import UploadNode from "./node/UploadNode"; +import InspectNode from "./node/InspectNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -31,6 +32,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem case WorkflowNodeType.Upload: return ; + + case WorkflowNodeType.Inspect: + return ; case WorkflowNodeType.Deploy: return ; diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index fb697e19..bf4c5be2 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -7,6 +7,7 @@ import { SendOutlined as SendOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon, SolutionOutlined as SolutionOutlinedIcon, + MonitorOutlined as MonitorOutlinedIcon, } from "@ant-design/icons"; import { Dropdown } from "antd"; @@ -27,6 +28,7 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { return [ [WorkflowNodeType.Apply, "workflow_node.apply.label", ], [WorkflowNodeType.Upload, "workflow_node.upload.label", ], + [WorkflowNodeType.Inspect, "workflow_node.inspect.label", ], [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], [WorkflowNodeType.Notify, "workflow_node.notify.label", ], [WorkflowNodeType.Branch, "workflow_node.branch.label", ], diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 7b2cb554..417db4af 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -156,4 +156,3 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP }; export default memo(ConditionNode); - diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index e040dc78..81022a28 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -350,4 +350,3 @@ const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { }; export default memo(ConditionNodeConfigForm); - diff --git a/ui/src/components/workflow/node/InspectNode.tsx b/ui/src/components/workflow/node/InspectNode.tsx new file mode 100644 index 00000000..fa4324e2 --- /dev/null +++ b/ui/src/components/workflow/node/InspectNode.tsx @@ -0,0 +1,90 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, Typography } from "antd"; +import { produce } from "immer"; + +import { type WorkflowNodeConfigForInspect, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; +import InspectNodeConfigForm, { type InspectNodeConfigFormInstance } from "./InspectNodeConfigForm"; + +export type InspectNodeProps = SharedNodeProps; + +const InspectNode = ({ node, disabled }: InspectNodeProps) => { + if (node.type !== WorkflowNodeType.Inspect) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); + } + + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForInspect; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Inspect) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForInspect) ?? {}; + return ( + + {config.domain ?? ""} + + ); + }, [node]); + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + }; + draft.validated = true; + }); + await updateNode(newNode); + } finally { + setFormPending(false); + } + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(InspectNode); diff --git a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx new file mode 100644 index 00000000..ea9573e5 --- /dev/null +++ b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx @@ -0,0 +1,85 @@ +import { forwardRef, memo, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type WorkflowNodeConfigForInspect } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; + +import { validDomainName, validPortNumber } from "@/utils/validators"; + +type InspectNodeConfigFormFieldValues = Partial; + +export type InspectNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: InspectNodeConfigFormFieldValues; + onValuesChange?: (values: InspectNodeConfigFormFieldValues) => void; +}; + +export type InspectNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): InspectNodeConfigFormFieldValues => { + return { + domain: "", + port: "443", + }; +}; + +const InspectNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + domain: z.string().refine((val) => validDomainName(val), { + message: t("workflow_node.inspect.form.domain.placeholder"), + }), + port: z.string().refine((val) => validPortNumber(val), { + message: t("workflow_node.inspect.form.port.placeholder"), + }), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeInspectConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as InspectNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof InspectNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as InspectNodeConfigFormInstance; + }); + + return ( +
+ + + + + + + +
+ ); + } +); + +export default memo(InspectNodeConfigForm); diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 05c936a7..6b951b49 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -31,6 +31,7 @@ export enum WorkflowNodeType { End = "end", Apply = "apply", Upload = "upload", + Inspect = "inspect", Deploy = "deploy", Notify = "notify", Branch = "branch", @@ -46,6 +47,7 @@ const workflowNodeTypeDefaultNames: Map = new Map([ [WorkflowNodeType.End, i18n.t("workflow_node.end.label")], [WorkflowNodeType.Apply, i18n.t("workflow_node.apply.label")], [WorkflowNodeType.Upload, i18n.t("workflow_node.upload.label")], + [WorkflowNodeType.Inspect, i18n.t("workflow_node.inspect.label")], [WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.label")], [WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")], [WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")], @@ -95,6 +97,17 @@ const workflowNodeTypeDefaultOutputs: Map = }, ], ], + [ + WorkflowNodeType.Inspect, + [ + { + name: "certificate", + type: "certificate", + required: true, + label: "证书", + }, + ], + ], [WorkflowNodeType.Deploy, []], [WorkflowNodeType.Notify, []], ]); @@ -145,6 +158,11 @@ export type WorkflowNodeConfigForUpload = { privateKey: string; }; +export type WorkflowNodeConfigForInspect = { + domain: string; + port: string; +}; + export type WorkflowNodeConfigForDeploy = { certificate: string; provider: string; @@ -313,6 +331,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} case WorkflowNodeType.Apply: case WorkflowNodeType.Upload: case WorkflowNodeType.Deploy: + case WorkflowNodeType.Inspect: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType); @@ -582,4 +601,3 @@ export const isAllNodesValidated = (node: WorkflowNode): boolean => { return true; }; - diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index a0288838..80cba031 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -753,6 +753,12 @@ "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.inspect.label": "Inspect certificate", + "workflow_node.inspect.form.domain.label": "Domain", + "workflow_node.inspect.form.domain.placeholder": "Please enter domain name", + "workflow_node.inspect.form.port.label": "Port", + "workflow_node.inspect.form.port.placeholder": "Please enter port", + "workflow_node.notify.label": "Notification", "workflow_node.notify.form.subject.label": "Subject", "workflow_node.notify.form.subject.placeholder": "Please enter subject", @@ -817,4 +823,3 @@ "workflow_node.execute_failure.label": "If the previous node failed ..." } - diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 9381aa71..4fce90b7 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -752,6 +752,12 @@ "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.inspect.label": "检查网站证书", + "workflow_node.inspect.form.domain.label": "域名", + "workflow_node.inspect.form.domain.placeholder": "请输入要检查的网站域名", + "workflow_node.inspect.form.port.label": "端口号", + "workflow_node.inspect.form.port.placeholder": "请输入要检查的端口号", + "workflow_node.notify.label": "推送通知", "workflow_node.notify.form.subject.label": "通知主题", "workflow_node.notify.form.subject.placeholder": "请输入通知主题", @@ -816,4 +822,3 @@ "workflow_node.execute_failure.label": "若前序节点执行失败…" } -