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": "若前序节点执行失败…"
}
-