From cd93a2d72c8c18ba4a393291216bdaffaf85c044 Mon Sep 17 00:00:00 2001
From: Fu Diwei <fudiwei@sina.com>
Date: Thu, 15 May 2025 21:48:30 +0800
Subject: [PATCH] feat: new acme dns-01 provider: netcup

---
 internal/applicant/providers.go               |  18 ++++
 internal/domain/access.go                     |   6 ++
 internal/domain/provider.go                   |   2 +
 .../lego-providers/netcup/netcup.go           |  40 +++++++++
 ui/public/imgs/providers/netcup.png           | Bin 0 -> 4093 bytes
 ui/src/components/access/AccessForm.tsx       |   3 +
 .../access/AccessFormNetcupConfig.tsx         |  79 ++++++++++++++++++
 .../node/DeployNodeConfigFormSSHConfig.tsx    |   1 -
 ui/src/domain/access.ts                       |   7 ++
 ui/src/domain/provider.ts                     |   4 +
 ui/src/i18n/locales/en/nls.access.json        |   9 ++
 ui/src/i18n/locales/en/nls.provider.json      |   1 +
 ui/src/i18n/locales/zh/nls.access.json        |   9 ++
 ui/src/i18n/locales/zh/nls.provider.json      |   1 +
 .../i18n/locales/zh/nls.workflow.nodes.json   |   2 +-
 15 files changed, 180 insertions(+), 2 deletions(-)
 create mode 100644 internal/pkg/core/applicant/acme-dns-01/lego-providers/netcup/netcup.go
 create mode 100644 ui/public/imgs/providers/netcup.png
 create mode 100644 ui/src/components/access/AccessFormNetcupConfig.tsx

diff --git a/internal/applicant/providers.go b/internal/applicant/providers.go
index 454f9376..d4d630c4 100644
--- a/internal/applicant/providers.go
+++ b/internal/applicant/providers.go
@@ -27,6 +27,7 @@ import (
 	pNamecheap "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/namecheap"
 	pNameDotCom "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/namedotcom"
 	pNameSilo "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/namesilo"
+	pNetcup "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/netcup"
 	pNS1 "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ns1"
 	pPorkbun "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/porkbun"
 	pPowerDNS "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/powerdns"
@@ -402,6 +403,23 @@ func createApplicantProvider(options *applicantProviderOptions) (challenge.Provi
 			return applicant, err
 		}
 
+	case domain.ACMEDns01ProviderTypeNetcup:
+		{
+			access := domain.AccessConfigForNetcup{}
+			if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil {
+				return nil, fmt.Errorf("failed to populate provider access config: %w", err)
+			}
+
+			applicant, err := pNetcup.NewChallengeProvider(&pNetcup.ChallengeProviderConfig{
+				CustomerNumber:        access.CustomerNumber,
+				ApiKey:                access.ApiKey,
+				ApiPassword:           access.ApiPassword,
+				DnsPropagationTimeout: options.DnsPropagationTimeout,
+				DnsTTL:                options.DnsTTL,
+			})
+			return applicant, err
+		}
+
 	case domain.ACMEDns01ProviderTypeNS1:
 		{
 			access := domain.AccessConfigForNS1{}
diff --git a/internal/domain/access.go b/internal/domain/access.go
index b2ff5e94..c18f846e 100644
--- a/internal/domain/access.go
+++ b/internal/domain/access.go
@@ -199,6 +199,12 @@ type AccessConfigForNameSilo struct {
 	ApiKey string `json:"apiKey"`
 }
 
+type AccessConfigForNetcup struct {
+	CustomerNumber string `json:"customerNumber"`
+	ApiKey         string `json:"apiKey"`
+	ApiPassword    string `json:"apiPassword"`
+}
+
 type AccessConfigForNS1 struct {
 	ApiKey string `json:"apiKey"`
 }
diff --git a/internal/domain/provider.go b/internal/domain/provider.go
index 728f89b6..4de70cd3 100644
--- a/internal/domain/provider.go
+++ b/internal/domain/provider.go
@@ -52,6 +52,7 @@ const (
 	AccessProviderTypeNamecheap           = AccessProviderType("namecheap")
 	AccessProviderTypeNameDotCom          = AccessProviderType("namedotcom")
 	AccessProviderTypeNameSilo            = AccessProviderType("namesilo")
+	AccessProviderTypeNetcup              = AccessProviderType("netcup")
 	AccessProviderTypeNS1                 = AccessProviderType("ns1")
 	AccessProviderTypePorkbun             = AccessProviderType("porkbun")
 	AccessProviderTypePowerDNS            = AccessProviderType("powerdns")
@@ -130,6 +131,7 @@ const (
 	ACMEDns01ProviderTypeNamecheap       = ACMEDns01ProviderType(AccessProviderTypeNamecheap)
 	ACMEDns01ProviderTypeNameDotCom      = ACMEDns01ProviderType(AccessProviderTypeNameDotCom)
 	ACMEDns01ProviderTypeNameSilo        = ACMEDns01ProviderType(AccessProviderTypeNameSilo)
+	ACMEDns01ProviderTypeNetcup          = ACMEDns01ProviderType(AccessProviderTypeNetcup)
 	ACMEDns01ProviderTypeNS1             = ACMEDns01ProviderType(AccessProviderTypeNS1)
 	ACMEDns01ProviderTypePorkbun         = ACMEDns01ProviderType(AccessProviderTypePorkbun)
 	ACMEDns01ProviderTypePowerDNS        = ACMEDns01ProviderType(AccessProviderTypePowerDNS)
diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/netcup/netcup.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/netcup/netcup.go
new file mode 100644
index 00000000..43d7a694
--- /dev/null
+++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/netcup/netcup.go
@@ -0,0 +1,40 @@
+package netcup
+
+import (
+	"time"
+
+	"github.com/go-acme/lego/v4/challenge"
+	"github.com/go-acme/lego/v4/providers/dns/netcup"
+)
+
+type ChallengeProviderConfig struct {
+	CustomerNumber        string `json:"customerNumber"`
+	ApiKey                string `json:"apiKey"`
+	ApiPassword           string `json:"apiPassword"`
+	DnsPropagationTimeout int32  `json:"dnsPropagationTimeout,omitempty"`
+	DnsTTL                int32  `json:"dnsTTL,omitempty"`
+}
+
+func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) {
+	if config == nil {
+		panic("config is nil")
+	}
+
+	providerConfig := netcup.NewDefaultConfig()
+	providerConfig.Customer = config.CustomerNumber
+	providerConfig.Key = config.ApiKey
+	providerConfig.Password = config.ApiPassword
+	if config.DnsPropagationTimeout != 0 {
+		providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
+	}
+	if config.DnsTTL != 0 {
+		providerConfig.TTL = int(config.DnsTTL)
+	}
+
+	provider, err := netcup.NewDNSProviderConfig(providerConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return provider, nil
+}
diff --git a/ui/public/imgs/providers/netcup.png b/ui/public/imgs/providers/netcup.png
new file mode 100644
index 0000000000000000000000000000000000000000..2f56ce118c3868be85c1eba20bf2592c673f04a0
GIT binary patch
literal 4093
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

literal 0
HcmV?d00001

diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx
index 02a71854..b7d374d3 100644
--- a/ui/src/components/access/AccessForm.tsx
+++ b/ui/src/components/access/AccessForm.tsx
@@ -46,6 +46,7 @@ import AccessFormMattermostConfig from "./AccessFormMattermostConfig";
 import AccessFormNamecheapConfig from "./AccessFormNamecheapConfig";
 import AccessFormNameDotComConfig from "./AccessFormNameDotComConfig";
 import AccessFormNameSiloConfig from "./AccessFormNameSiloConfig";
+import AccessFormNetcupConfig from "./AccessFormNetcupConfig";
 import AccessFormNS1Config from "./AccessFormNS1Config";
 import AccessFormPorkbunConfig from "./AccessFormPorkbunConfig";
 import AccessFormPowerDNSConfig from "./AccessFormPowerDNSConfig";
@@ -242,6 +243,8 @@ const AccessForm = forwardRef<AccessFormInstance, AccessFormProps>(({ className,
         return <AccessFormNameDotComConfig {...nestedFormProps} />;
       case ACCESS_PROVIDERS.NAMESILO:
         return <AccessFormNameSiloConfig {...nestedFormProps} />;
+      case ACCESS_PROVIDERS.NETCUP:
+        return <AccessFormNetcupConfig {...nestedFormProps} />;
       case ACCESS_PROVIDERS.NS1:
         return <AccessFormNS1Config {...nestedFormProps} />;
       case ACCESS_PROVIDERS.PORKBUN:
diff --git a/ui/src/components/access/AccessFormNetcupConfig.tsx b/ui/src/components/access/AccessFormNetcupConfig.tsx
new file mode 100644
index 00000000..c5d4bfc6
--- /dev/null
+++ b/ui/src/components/access/AccessFormNetcupConfig.tsx
@@ -0,0 +1,79 @@
+import { useTranslation } from "react-i18next";
+import { Form, type FormInstance, Input } from "antd";
+import { createSchemaFieldRule } from "antd-zod";
+import { z } from "zod";
+
+import { type AccessConfigForNetcup } from "@/domain/access";
+
+type AccessFormNetcupConfigFieldValues = Nullish<AccessConfigForNetcup>;
+
+export type AccessFormNetcupConfigProps = {
+  form: FormInstance;
+  formName: string;
+  disabled?: boolean;
+  initialValues?: AccessFormNetcupConfigFieldValues;
+  onValuesChange?: (values: AccessFormNetcupConfigFieldValues) => void;
+};
+
+const initFormModel = (): AccessFormNetcupConfigFieldValues => {
+  return {
+    customerNumber: "",
+    apiKey: "",
+    apiPassword: "",
+  };
+};
+
+const AccessFormNetcupConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormNetcupConfigProps) => {
+  const { t } = useTranslation();
+
+  const formSchema = z.object({
+    customerNumber: z.string().nonempty(t("access.form.netcup_customer_number.placeholder")).trim(),
+    apiKey: z.string().nonempty(t("access.form.netcup_api_key.placeholder")).trim(),
+    apiPassword: z.string().nonempty(t("access.form.netcup_api_password.placeholder")).trim(),
+  });
+  const formRule = createSchemaFieldRule(formSchema);
+
+  const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
+    onValuesChange?.(values);
+  };
+
+  return (
+    <Form
+      form={formInst}
+      disabled={disabled}
+      initialValues={initialValues ?? initFormModel()}
+      layout="vertical"
+      name={formName}
+      onValuesChange={handleFormChange}
+    >
+      <Form.Item
+        name="customerNumber"
+        label={t("access.form.netcup_customer_number.label")}
+        rules={[formRule]}
+        tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.netcup_customer_number.tooltip") }}></span>}
+      >
+        <Input autoComplete="new-password" placeholder={t("access.form.netcup_customer_number.placeholder")} />
+      </Form.Item>
+
+      <Form.Item
+        name="apiKey"
+        label={t("access.form.netcup_api_key.label")}
+        rules={[formRule]}
+        tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.netcup_api_key.tooltip") }}></span>}
+      >
+        <Input.Password autoComplete="new-password" placeholder={t("access.form.netcup_api_key.placeholder")} />
+      </Form.Item>
+
+      <Form.Item
+        name="apiPassword"
+        label={t("access.form.netcup_api_password.label")}
+        rules={[formRule]}
+        tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.netcup_api_password.tooltip") }}></span>}
+      >
+        <Input.Password autoComplete="new-password" placeholder={t("access.form.netcup_api_password.placeholder")} />
+      </Form.Item>
+    </Form>
+  );
+};
+
+export default AccessFormNetcupConfig;
diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormSSHConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormSSHConfig.tsx
index 0f3f3082..e99a2431 100644
--- a/ui/src/components/workflow/node/DeployNodeConfigFormSSHConfig.tsx
+++ b/ui/src/components/workflow/node/DeployNodeConfigFormSSHConfig.tsx
@@ -131,7 +131,6 @@ info "Completed"
       return `# *** 需要 root 权限 ***
 # 脚本参考 https://github.com/lfgyx/fnos_certificate_update/blob/main/src/update_cert.sh
 
-
 # 请将以下变量替换为实际值
 # 飞牛证书实际存放路径请在 \`/usr/trim/etc/network_cert_all.conf\` 中查看,注意不要修改文件名
 $tmpFullchainPath = "${params?.certPath || "<your-fullchain-cert-path>"}" # 证书文件路径(与表单中保持一致)
diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts
index b3ba29ca..e0cce59d 100644
--- a/ui/src/domain/access.ts
+++ b/ui/src/domain/access.ts
@@ -41,6 +41,7 @@ export interface AccessModel extends BaseModel {
       | AccessConfigForNamecheap
       | AccessConfigForNameDotCom
       | AccessConfigForNameSilo
+      | AccessConfigForNetcup
       | AccessConfigForPorkbun
       | AccessConfigForPowerDNS
       | AccessConfigForProxmoxVE
@@ -249,6 +250,12 @@ export type AccessConfigForNameSilo = {
   apiKey: string;
 };
 
+export type AccessConfigForNetcup = {
+  customerNumber: string;
+  apiKey: string;
+  apiPassword: string;
+};
+
 export type AccessConfigForNS1 = {
   apiKey: string;
 };
diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts
index 6fc29ad4..5cc50534 100644
--- a/ui/src/domain/provider.ts
+++ b/ui/src/domain/provider.ts
@@ -43,6 +43,7 @@ export const ACCESS_PROVIDERS = Object.freeze({
   NAMECHEAP: "namecheap",
   NAMEDOTCOM: "namedotcom",
   NAMESILO: "namesilo",
+  NETCUP: "netcup",
   NS1: "ns1",
   PORKBUN: "porkbun",
   POWERDNS: "powerdns",
@@ -132,6 +133,7 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
     [ACCESS_PROVIDERS.GODADDY, "provider.godaddy", "/imgs/providers/godaddy.svg", [ACCESS_USAGES.DNS]],
     [ACCESS_PROVIDERS.NAMECHEAP, "provider.namecheap", "/imgs/providers/namecheap.svg", [ACCESS_USAGES.DNS]],
     [ACCESS_PROVIDERS.NAMEDOTCOM, "provider.namedotcom", "/imgs/providers/namedotcom.svg", [ACCESS_USAGES.DNS]],
+    [ACCESS_PROVIDERS.NETCUP, "provider.netcup", "/imgs/providers/netcup.png", [ACCESS_USAGES.DNS]],
     [ACCESS_PROVIDERS.NAMESILO, "provider.namesilo", "/imgs/providers/namesilo.svg", [ACCESS_USAGES.DNS]],
     [ACCESS_PROVIDERS.NS1, "provider.ns1", "/imgs/providers/ns1.svg", [ACCESS_USAGES.DNS]],
     [ACCESS_PROVIDERS.PORKBUN, "provider.porkbun", "/imgs/providers/porkbun.svg", [ACCESS_USAGES.DNS]],
@@ -249,6 +251,7 @@ export const ACME_DNS01_PROVIDERS = Object.freeze({
   NAMECHEAP: `${ACCESS_PROVIDERS.NAMECHEAP}`,
   NAMEDOTCOM: `${ACCESS_PROVIDERS.NAMEDOTCOM}`,
   NAMESILO: `${ACCESS_PROVIDERS.NAMESILO}`,
+  NETCUP: `${ACCESS_PROVIDERS.NETCUP}`,
   NS1: `${ACCESS_PROVIDERS.NS1}`,
   PORKBUN: `${ACCESS_PROVIDERS.PORKBUN}`,
   POWERDNS: `${ACCESS_PROVIDERS.POWERDNS}`,
@@ -299,6 +302,7 @@ export const acmeDns01ProvidersMap: Map<ACMEDns01Provider["type"] | string, ACME
     [ACME_DNS01_PROVIDERS.NAMECHEAP, "provider.namecheap"],
     [ACME_DNS01_PROVIDERS.NAMEDOTCOM, "provider.namedotcom"],
     [ACME_DNS01_PROVIDERS.NAMESILO, "provider.namesilo"],
+    [ACME_DNS01_PROVIDERS.NETCUP, "provider.netcup"],
     [ACME_DNS01_PROVIDERS.NS1, "provider.ns1"],
     [ACME_DNS01_PROVIDERS.PORKBUN, "provider.porkbun"],
     [ACME_DNS01_PROVIDERS.VERCEL, "provider.vercel"],
diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json
index 2a4897c5..910e7a04 100644
--- a/ui/src/i18n/locales/en/nls.access.json
+++ b/ui/src/i18n/locales/en/nls.access.json
@@ -262,6 +262,15 @@
   "access.form.namesilo_api_key.label": "NameSilo API key",
   "access.form.namesilo_api_key.placeholder": "Please enter NameSilo API key",
   "access.form.namesilo_api_key.tooltip": "For more information, see <a href=\"https://www.namesilo.com/support/v2/articles/account-options/api-manager\" target=\"_blank\">https://www.namesilo.com/support/v2/articles/account-options/api-manager</a>",
+  "access.form.netcup_customer_number.label": "netcup customer number",
+  "access.form.netcup_customer_number.placeholder": "Please enter netcup customer number",
+  "access.form.netcup_customer_number.tooltip": "For more information, see <a href=\"https://helpcenter.netcup.com/en/wiki/general/ccp-login/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/ccp-login/</a>",
+  "access.form.netcup_api_key.label": "netcup API key",
+  "access.form.netcup_api_key.placeholder": "Please enter netcup API key",
+  "access.form.netcup_api_key.tooltip": "For more information, see <a href=\"https://helpcenter.netcup.com/en/wiki/general/our-api/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/our-api/</a>",
+  "access.form.netcup_api_password.label": "netcup API password",
+  "access.form.netcup_api_password.placeholder": "Please enter netcup API password",
+  "access.form.netcup_api_password.tooltip": "For more information, see <a href=\"https://helpcenter.netcup.com/en/wiki/general/our-api/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/our-api/</a>",
   "access.form.ns1_api_key.label": "NS1 API key",
   "access.form.ns1_api_key.placeholder": "Please enter NS1 API key",
   "access.form.ns1_api_key.tooltip": "For more information, see <a href=\"https://www.ibm.com/docs/en/ns1-connect?topic=introduction-using-api\" target=\"_blank\">https://www.ibm.com/docs/en/ns1-connect?topic=introduction-using-api</a>",
diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json
index 0c140684..1f7e5589 100644
--- a/ui/src/i18n/locales/en/nls.provider.json
+++ b/ui/src/i18n/locales/en/nls.provider.json
@@ -90,6 +90,7 @@
   "provider.namecheap": "Namecheap",
   "provider.namedotcom": "Name.com",
   "provider.namesilo": "NameSilo",
+  "provider.netcup": "netcup",
   "provider.ns1": "NS1 (IBM NS1 Connect)",
   "provider.porkbun": "Porkbun",
   "provider.powerdns": "PowerDNS",
diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json
index 4839d6ad..fd993178 100644
--- a/ui/src/i18n/locales/zh/nls.access.json
+++ b/ui/src/i18n/locales/zh/nls.access.json
@@ -256,6 +256,15 @@
   "access.form.namesilo_api_key.label": "NameSilo API Key",
   "access.form.namesilo_api_key.placeholder": "请输入 NameSilo API Key",
   "access.form.namesilo_api_key.tooltip": "这是什么?请参阅 <a href=\"https://www.namesilo.com/support/v2/articles/account-options/api-manager\" target=\"_blank\">https://www.namesilo.com/support/v2/articles/account-options/api-manager</a>",
+  "access.form.netcup_customer_number.label": "netcup 客户编号",
+  "access.form.netcup_customer_number.placeholder": "请输入 netcup 客户编号",
+  "access.form.netcup_customer_number.tooltip": "这是什么?请参阅 <a href=\"https://helpcenter.netcup.com/en/wiki/general/ccp-login/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/ccp-login/</a>",
+  "access.form.netcup_api_key.label": "netcup API Key",
+  "access.form.netcup_api_key.placeholder": "请输入 netcup API Key",
+  "access.form.netcup_api_key.tooltip": "这是什么?请参阅 <a href=\"https://helpcenter.netcup.com/en/wiki/general/our-api/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/our-api/</a>",
+  "access.form.netcup_api_password.label": "netcup API Key 密码",
+  "access.form.netcup_api_password.placeholder": "请输入 netcup API Key 密码",
+  "access.form.netcup_api_password.tooltip": "这是什么?请参阅 <a href=\"https://helpcenter.netcup.com/en/wiki/general/our-api/\" target=\"_blank\">https://helpcenter.netcup.com/en/wiki/general/our-api/</a>",
   "access.form.ns1_api_key.label": "NS1 API Key",
   "access.form.ns1_api_key.placeholder": "请输入 NS1 API Key",
   "access.form.ns1_api_key.tooltip": "这是什么?请参阅 <a href=\"https://www.ibm.com/docs/zh/ns1-connect?topic=introduction-using-api\" target=\"_blank\">https://www.ibm.com/docs/zh/ns1-connect?topic=introduction-using-api</a>",
diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json
index 5626426e..739fcebf 100644
--- a/ui/src/i18n/locales/zh/nls.provider.json
+++ b/ui/src/i18n/locales/zh/nls.provider.json
@@ -90,6 +90,7 @@
   "provider.namecheap": "Namecheap",
   "provider.namedotcom": "Name.com",
   "provider.namesilo": "NameSilo",
+  "provider.netcup": "netcup",
   "provider.ns1": "NS1 (IBM NS1 Connect)",
   "provider.porkbun": "Porkbun",
   "provider.powerdns": "PowerDNS",
diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
index 378cff37..d1829698 100644
--- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
@@ -549,7 +549,7 @@
   "workflow_node.deploy.form.ssh_preset_scripts.option.ps_backup_files.label": "PowerShell - 备份原证书文件",
   "workflow_node.deploy.form.ssh_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - 重启 nginx 进程",
   "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_synologydsm_ssl.label": "POSIX Bash - 替换群晖 DSM 证书",
-  "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - 替换飞牛 OS 证书",
+  "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - 替换飞牛 fnOS 证书",
   "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_iis.label": "PowerShell - 导入并绑定到 IIS",
   "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_netsh.label": "PowerShell - 导入并绑定到 netsh",
   "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_rdp.label": "PowerShell - 导入并绑定到 RDP",