From e7870e2b0599292cbf4cb50ca7a431d3101d6c0c Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sat, 26 Oct 2024 22:22:28 +0800 Subject: [PATCH 01/11] feat: support specified shell on deployment to local --- internal/deployer/local.go | 106 +++++----- internal/deployer/ssh.go | 103 +++++----- internal/pkg/utils/fs/fs.go | 51 +++++ .../components/certimate/DeployEditDialog.tsx | 5 +- ui/src/components/certimate/DeployToLocal.tsx | 194 ++++++++++++++++++ ui/src/components/certimate/DeployToSSH.tsx | 80 +++++--- ui/src/i18n/locales/en/nls.domain.json | 21 +- ui/src/i18n/locales/zh/nls.domain.json | 18 +- 8 files changed, 436 insertions(+), 142 deletions(-) create mode 100644 internal/pkg/utils/fs/fs.go create mode 100644 ui/src/components/certimate/DeployToLocal.tsx diff --git a/internal/deployer/local.go b/internal/deployer/local.go index 784660b6..7b6e224c 100644 --- a/internal/deployer/local.go +++ b/internal/deployer/local.go @@ -1,15 +1,15 @@ package deployer import ( + "bytes" "context" "encoding/json" "fmt" - "os" "os/exec" - "path/filepath" "runtime" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/fs" ) type LocalDeployer struct { @@ -38,74 +38,84 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { return err } - preCommand := getDeployString(d.option.DeployConfig, "preCommand") - + // 执行前置命令 + preCommand := d.option.DeployConfig.GetConfigAsString("preCommand") if preCommand != "" { - if err := execCmd(preCommand); err != nil { - return fmt.Errorf("执行前置命令失败: %w", err) + stdout, stderr, err := d.execCommand(preCommand) + if err != nil { + return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr) } + + d.infos = append(d.infos, toStr("执行前置命令成功", stdout)) } - // 复制证书文件 - if err := copyFile(getDeployString(d.option.DeployConfig, "certPath"), d.option.Certificate.Certificate); err != nil { - return fmt.Errorf("复制证书失败: %w", err) - } + // 写入证书和私钥文件 + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", "pem") { + case "pfx": + // TODO: pfx + return fmt.Errorf("not implemented") - // 复制私钥文件 - if err := copyFile(getDeployString(d.option.DeployConfig, "keyPath"), d.option.Certificate.PrivateKey); err != nil { - return fmt.Errorf("复制私钥失败: %w", err) + case "pem": + if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) + + if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil { + return fmt.Errorf("failed to save private key file: %w", err) + } + + d.infos = append(d.infos, toStr("保存私钥成功", nil)) } // 执行命令 - if err := execCmd(getDeployString(d.option.DeployConfig, "command")); err != nil { - return fmt.Errorf("执行命令失败: %w", err) + command := d.option.DeployConfig.GetConfigAsString("command") + if command != "" { + stdout, stderr, err := d.execCommand(command) + if err != nil { + return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } + + d.infos = append(d.infos, toStr("执行命令成功", stdout)) } return nil } -func execCmd(command string) error { - // 执行命令 +func (d *LocalDeployer) execCommand(command string) (string, string, error) { var cmd *exec.Cmd - if runtime.GOOS == "windows" { + switch d.option.DeployConfig.GetConfigAsString("shell") { + case "cmd": cmd = exec.Command("cmd", "/C", command) - } else { + + case "powershell": + cmd = exec.Command("powershell", "-Command", command) + + case "sh": cmd = exec.Command("sh", "-c", command) + + case "": + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + + default: + return "", "", fmt.Errorf("unsupported shell") } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + var stdoutBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf err := cmd.Run() if err != nil { - return fmt.Errorf("执行命令失败: %w", err) + return "", "", fmt.Errorf("failed to execute script: %w", err) } - return nil -} - -func copyFile(path string, content string) error { - dir := filepath.Dir(path) - - // 如果目录不存在,创建目录 - err := os.MkdirAll(dir, os.ModePerm) - if err != nil { - return fmt.Errorf("创建目录失败: %w", err) - } - - // 创建或打开文件 - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("创建文件失败: %w", err) - } - defer file.Close() - - // 写入内容到文件 - _, err = file.Write([]byte(content)) - if err != nil { - return fmt.Errorf("写入文件失败: %w", err) - } - - return nil + return stdoutBuf.String(), stderrBuf.String(), err } diff --git a/internal/deployer/ssh.go b/internal/deployer/ssh.go index 551e8634..e2f11c45 100644 --- a/internal/deployer/ssh.go +++ b/internal/deployer/ssh.go @@ -6,10 +6,10 @@ import ( "encoding/json" "fmt" "os" - xpath "path" + "path/filepath" "github.com/pkg/sftp" - sshPkg "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh" "github.com/usual2970/certimate/internal/domain" ) @@ -41,49 +41,84 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { } // 连接 - client, err := d.createClient(access) + client, err := d.createSshClient(access) if err != nil { return err } defer client.Close() - d.infos = append(d.infos, toStr("ssh连接成功", nil)) + d.infos = append(d.infos, toStr("SSH 连接成功", nil)) // 执行前置命令 - preCommand := getDeployString(d.option.DeployConfig, "preCommand") + preCommand := d.option.DeployConfig.GetConfigAsString("preCommand") if preCommand != "" { stdout, stderr, err := d.sshExecCommand(client, preCommand) if err != nil { return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr) } + + d.infos = append(d.infos, toStr("SSH 执行前置命令成功", stdout)) } // 上传证书 - if err := d.upload(client, d.option.Certificate.Certificate, getDeployString(d.option.DeployConfig, "certPath")); err != nil { - return fmt.Errorf("failed to upload certificate: %w", err) + if err := d.uploadFile(client, d.option.Certificate.Certificate, d.option.DeployConfig.GetConfigAsString("certPath")); err != nil { + return fmt.Errorf("failed to upload certificate file: %w", err) } - d.infos = append(d.infos, toStr("ssh上传证书成功", nil)) + d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) // 上传私钥 - if err := d.upload(client, d.option.Certificate.PrivateKey, getDeployString(d.option.DeployConfig, "keyPath")); err != nil { - return fmt.Errorf("failed to upload private key: %w", err) + if err := d.uploadFile(client, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("keyPath")); err != nil { + return fmt.Errorf("failed to upload private key file: %w", err) } - d.infos = append(d.infos, toStr("ssh上传私钥成功", nil)) + d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil)) // 执行命令 - stdout, stderr, err := d.sshExecCommand(client, getDeployString(d.option.DeployConfig, "command")) - if err != nil { - return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) - } + command := d.option.DeployConfig.GetConfigAsString("command") + if command != "" { + stdout, stderr, err := d.sshExecCommand(client, command) + if err != nil { + return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } - d.infos = append(d.infos, toStr("ssh执行命令成功", stdout)) + d.infos = append(d.infos, toStr("SSH 执行命令成功", stdout)) + } return nil } -func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (string, string, error) { +func (d *SSHDeployer) createSshClient(access *domain.SSHAccess) (*ssh.Client, error) { + var authMethod ssh.AuthMethod + + if access.Key != "" { + var signer ssh.Signer + var err error + + if access.KeyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(access.Key)) + } + + if err != nil { + return nil, err + } + authMethod = ssh.PublicKeys(signer) + } else { + authMethod = ssh.Password(access.Password) + } + + return ssh.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &ssh.ClientConfig{ + User: access.Username, + Auth: []ssh.AuthMethod{ + authMethod, + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) +} + +func (d *SSHDeployer) sshExecCommand(client *ssh.Client, command string) (string, string, error) { session, err := client.NewSession() if err != nil { return "", "", fmt.Errorf("failed to create ssh session: %w", err) @@ -98,14 +133,14 @@ func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (str return stdoutBuf.String(), stderrBuf.String(), err } -func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error { +func (d *SSHDeployer) uploadFile(client *ssh.Client, path string, content string) error { sftpCli, err := sftp.NewClient(client) if err != nil { return fmt.Errorf("failed to create sftp client: %w", err) } defer sftpCli.Close() - if err := sftpCli.MkdirAll(xpath.Dir(path)); err != nil { + if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil { return fmt.Errorf("failed to create remote directory: %w", err) } @@ -122,33 +157,3 @@ func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error return nil } - -func (d *SSHDeployer) createClient(access *domain.SSHAccess) (*sshPkg.Client, error) { - var authMethod sshPkg.AuthMethod - - if access.Key != "" { - var signer sshPkg.Signer - var err error - - if access.KeyPassphrase != "" { - signer, err = sshPkg.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase)) - } else { - signer, err = sshPkg.ParsePrivateKey([]byte(access.Key)) - } - - if err != nil { - return nil, err - } - authMethod = sshPkg.PublicKeys(signer) - } else { - authMethod = sshPkg.Password(access.Password) - } - - return sshPkg.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &sshPkg.ClientConfig{ - User: access.Username, - Auth: []sshPkg.AuthMethod{ - authMethod, - }, - HostKeyCallback: sshPkg.InsecureIgnoreHostKey(), - }) -} diff --git a/internal/pkg/utils/fs/fs.go b/internal/pkg/utils/fs/fs.go new file mode 100644 index 00000000..a8db4c04 --- /dev/null +++ b/internal/pkg/utils/fs/fs.go @@ -0,0 +1,51 @@ +package fs + +import ( + "fmt" + "os" + "path/filepath" +) + +// 与 `WriteFile` 类似,但写入的是字符串内容。 +// +// 入参: +// - path: 文件路径。 +// - content: 文件内容。 +// +// 出参: +// - 错误。 +func WriteFileString(path string, content string) error { + return WriteFile(path, []byte(content)) +} + +// 将数据写入指定路径的文件。 +// 如果目录不存在,将会递归创建目录。 +// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。 +// +// 入参: +// - path: 文件路径。 +// - data: 文件数据字节数组。 +// +// 出参: +// - 错误。 +func WriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/ui/src/components/certimate/DeployEditDialog.tsx b/ui/src/components/certimate/DeployEditDialog.tsx index 7ef5f291..24d122c7 100644 --- a/ui/src/components/certimate/DeployEditDialog.tsx +++ b/ui/src/components/certimate/DeployEditDialog.tsx @@ -17,6 +17,7 @@ import DeployToTencentCOS from "./DeployToTencentCOS"; import DeployToHuaweiCloudCDN from "./DeployToHuaweiCloudCDN"; import DeployToHuaweiCloudELB from "./DeployToHuaweiCloudELB"; import DeployToQiniuCDN from "./DeployToQiniuCDN"; +import DeployToLocal from "./DeployToLocal"; import DeployToSSH from "./DeployToSSH"; import DeployToWebhook from "./DeployToWebhook"; import DeployToKubernetesSecret from "./DeployToKubernetesSecret"; @@ -136,8 +137,10 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro case "qiniu-cdn": childComponent = ; break; - case "ssh": case "local": + childComponent = ; + break; + case "ssh": childComponent = ; break; case "webhook": diff --git a/ui/src/components/certimate/DeployToLocal.tsx b/ui/src/components/certimate/DeployToLocal.tsx new file mode 100644 index 00000000..2c147936 --- /dev/null +++ b/ui/src/components/certimate/DeployToLocal.tsx @@ -0,0 +1,194 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { produce } from "immer"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; +import { useDeployEditContext } from "./DeployEdit"; +import { cn } from "@/lib/utils"; + +const DeployToLocal = () => { + const { t } = useTranslation(); + + const { deploy: data, setDeploy, error, setError } = useDeployEditContext(); + + useEffect(() => { + if (!data.id) { + setDeploy({ + ...data, + config: { + certPath: "/etc/nginx/ssl/nginx.crt", + keyPath: "/etc/nginx/ssl/nginx.key", + shell: "sh", + preCommand: "", + command: "sudo service nginx reload", + }, + }); + } + }, []); + + useEffect(() => { + setError({}); + }, []); + + const formSchema = z.object({ + certPath: z.string().min(1, t("domain.deployment.form.file_cert_path.placeholder")), + keyPath: z.string().min(1, t("domain.deployment.form.file_key_path.placeholder")), + shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], { + message: t("domain.deployment.form.shell.placeholder"), + }), + preCommand: z.string().optional(), + command: z.string().optional(), + }); + + useEffect(() => { + const res = formSchema.safeParse(data.config); + if (!res.success) { + setError({ + ...error, + certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, + keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + shell: res.error.errors.find((e) => e.path[0] === "shell")?.message, + preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message, + command: res.error.errors.find((e) => e.path[0] === "command")?.message, + }); + } else { + setError({ + ...error, + certPath: undefined, + keyPath: undefined, + shell: undefined, + preCommand: undefined, + command: undefined, + }); + } + }, [data]); + + const getOptionCls = (val: string) => { + if (data.config?.shell === val) { + return "border-primary dark:border-primary"; + } + + return ""; + }; + + return ( + <> +
+
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.certPath}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.keyPath}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = val; + }); + setDeploy(newData); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
{error?.shell}
+
+ +
+ + +
{error?.preCommand}
+
+ +
+ + +
{error?.command}
+
+
+ + ); +}; + +export default DeployToLocal; diff --git a/ui/src/components/certimate/DeployToSSH.tsx b/ui/src/components/certimate/DeployToSSH.tsx index 80eb15fb..edb869fe 100644 --- a/ui/src/components/certimate/DeployToSSH.tsx +++ b/ui/src/components/certimate/DeployToSSH.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { z } from "zod"; import { produce } from "immer"; import { Input } from "@/components/ui/input"; @@ -9,13 +10,8 @@ import { useDeployEditContext } from "./DeployEdit"; const DeployToSSH = () => { const { t } = useTranslation(); - const { setError } = useDeployEditContext(); - useEffect(() => { - setError({}); - }, []); - - const { deploy: data, setDeploy } = useDeployEditContext(); + const { deploy: data, setDeploy, error, setError } = useDeployEditContext(); useEffect(() => { if (!data.id) { @@ -31,79 +27,107 @@ const DeployToSSH = () => { } }, []); + useEffect(() => { + setError({}); + }, []); + + const formSchema = z.object({ + certPath: z.string().min(1, t("domain.deployment.form.file_cert_path.placeholder")), + keyPath: z.string().min(1, t("domain.deployment.form.file_key_path.placeholder")), + preCommand: z.string().optional(), + command: z.string().optional(), + }); + + useEffect(() => { + const res = formSchema.safeParse(data.config); + if (!res.success) { + setError({ + ...error, + certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, + keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message, + command: res.error.errors.find((e) => e.path[0] === "command")?.message, + }); + } else { + setError({ + ...error, + certPath: undefined, + keyPath: undefined, + preCommand: undefined, + command: undefined, + }); + } + }, [data]); + return ( <>
- + { const newData = produce(data, (draft) => { - if (!draft.config) { - draft.config = {}; - } - draft.config.certPath = e.target.value; + draft.config ??= {}; + draft.config.certPath = e.target.value?.trim(); }); setDeploy(newData); }} /> +
{error?.certPath}
- + { const newData = produce(data, (draft) => { - if (!draft.config) { - draft.config = {}; - } - draft.config.keyPath = e.target.value; + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); }); setDeploy(newData); }} /> +
{error?.keyPath}
- + +
{error?.preCommand}
- + +
{error?.command}
diff --git a/ui/src/i18n/locales/en/nls.domain.json b/ui/src/i18n/locales/en/nls.domain.json index 80f1a4d7..1a271ec4 100644 --- a/ui/src/i18n/locales/en/nls.domain.json +++ b/ui/src/i18n/locales/en/nls.domain.json @@ -86,14 +86,19 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "Please enter ELB loadbalancer ID", "domain.deployment.form.huaweicloud_elb_listener_id.label": "Listener ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "Please enter ELB listener ID", - "domain.deployment.form.ssh_key_path.label": "Private Key Save Path", - "domain.deployment.form.ssh_key_path.placeholder": "Please enter private key save path", - "domain.deployment.form.ssh_cert_path.label": "Certificate Save Path", - "domain.deployment.form.ssh_cert_path.placeholder": "Please enter certificate save path", - "domain.deployment.form.ssh_pre_command.label": "Pre-deployment Command", - "domain.deployment.form.ssh_pre_command.placeholder": "Command to be executed before deploying the certificate", - "domain.deployment.form.ssh_command.label": "Command", - "domain.deployment.form.ssh_command.placeholder": "Please enter command", + "domain.deployment.form.file_cert_path.label": "Certificate Save Path", + "domain.deployment.form.file_cert_path.placeholder": "Please enter certificate save path", + "domain.deployment.form.file_key_path.label": "Private Key Save Path", + "domain.deployment.form.file_key_path.placeholder": "Please enter private key save path", + "domain.deployment.form.shell.label": "Shell", + "domain.deployment.form.shell.placeholder": "Please select shell environment", + "domain.deployment.form.shell.option.sh.label": "POSIX Bash (Linux)", + "domain.deployment.form.shell.option.cmd.label": "CMD (Windows)", + "domain.deployment.form.shell.option.powershell.label": "PowerShell (Windows)", + "domain.deployment.form.shell_pre_command.label": "Pre-deployment Command", + "domain.deployment.form.shell_pre_command.placeholder": "Command to be executed before deploying the certificate", + "domain.deployment.form.shell_command.label": "Command", + "domain.deployment.form.shell_command.placeholder": "Please enter command", "domain.deployment.form.k8s_namespace.label": "Namespace", "domain.deployment.form.k8s_namespace.placeholder": "Please enter namespace", "domain.deployment.form.k8s_secret_name.label": "Secret Name", diff --git a/ui/src/i18n/locales/zh/nls.domain.json b/ui/src/i18n/locales/zh/nls.domain.json index ae7f4d0f..10c53c9b 100644 --- a/ui/src/i18n/locales/zh/nls.domain.json +++ b/ui/src/i18n/locales/zh/nls.domain.json @@ -86,14 +86,16 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "请输入负载均衡器 ID(可从华为云控制面板获取)", "domain.deployment.form.huaweicloud_elb_listener_id.label": "监听器 ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "请输入监听器 ID(可从华为云控制面板获取)", - "domain.deployment.form.ssh_key_path.label": "私钥保存路径", - "domain.deployment.form.ssh_key_path.placeholder": "请输入私钥保存路径", - "domain.deployment.form.ssh_cert_path.label": "证书保存路径", - "domain.deployment.form.ssh_cert_path.placeholder": "请输入证书保存路径", - "domain.deployment.form.ssh_pre_command.label": "前置命令", - "domain.deployment.form.ssh_pre_command.placeholder": "在部署证书前执行的命令", - "domain.deployment.form.ssh_command.label": "命令", - "domain.deployment.form.ssh_command.placeholder": "请输入要执行的命令", + "domain.deployment.form.file_key_path.label": "私钥保存路径", + "domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径", + "domain.deployment.form.file_cert_path.label": "证书保存路径", + "domain.deployment.form.file_cert_path.placeholder": "请输入证书保存路径", + "domain.deployment.form.shell.label": "Shell", + "domain.deployment.form.shell.placeholder": "请选择命令执行环境", + "domain.deployment.form.shell_pre_command.label": "前置命令", + "domain.deployment.form.shell_pre_command.placeholder": "在部署证书前执行的命令", + "domain.deployment.form.shell_command.label": "命令", + "domain.deployment.form.shell_command.placeholder": "请输入要执行的命令", "domain.deployment.form.k8s_namespace.label": "命名空间", "domain.deployment.form.k8s_namespace.placeholder": "请输入 K8S 命名空间", "domain.deployment.form.k8s_secret_name.label": "Secret 名称", From adad5d86badf8cd99c13262a36a79265974c0e92 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sat, 26 Oct 2024 23:49:26 +0800 Subject: [PATCH 02/11] feat: support specified format on deployment to local/ssh --- go.mod | 5 +- go.sum | 2 + internal/deployer/deployer.go | 32 ++++ internal/deployer/local.go | 22 ++- internal/deployer/ssh.go | 46 ++++-- internal/pkg/utils/fs/fs.go | 2 +- .../certimate/DeployToHuaweiCloudELB.tsx | 2 +- ui/src/components/certimate/DeployToLocal.tsx | 142 +++++++++++++++--- ui/src/components/certimate/DeployToSSH.tsx | 136 ++++++++++++++--- ui/src/i18n/locales/en/nls.domain.json | 4 + ui/src/i18n/locales/zh/nls.domain.json | 8 +- ui/src/pages/dashboard/Dashboard.tsx | 2 +- ui/src/pages/history/History.tsx | 2 +- 13 files changed, 329 insertions(+), 76 deletions(-) diff --git a/go.mod b/go.mod index dff90fd5..86e87380 100644 --- a/go.mod +++ b/go.mod @@ -24,8 +24,10 @@ require ( github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992 golang.org/x/crypto v0.28.0 + k8s.io/api v0.31.1 k8s.io/apimachinery v0.31.1 k8s.io/client-go v0.31.1 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( @@ -58,7 +60,6 @@ require ( go.mongodb.org/mongo-driver v1.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/api v0.31.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect @@ -151,7 +152,7 @@ require ( golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.8.0 golang.org/x/sys v0.26.0 // indirect golang.org/x/term v0.25.0 // indirect golang.org/x/text v0.19.0 // indirect diff --git a/go.sum b/go.sum index f666c84a..0ee4a995 100644 --- a/go.sum +++ b/go.sum @@ -763,3 +763,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 512fd748..17c9091d 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -2,12 +2,15 @@ package deployer import ( "context" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "strings" "github.com/pocketbase/pocketbase/models" + "software.sslmate.com/src/go-pkcs12" "github.com/usual2970/certimate/internal/applicant" "github.com/usual2970/certimate/internal/domain" @@ -180,3 +183,32 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string { return rs } + +func convertPemToPfx(certificate string, privateKey string, password string) ([]byte, error) { + // TODO: refactor + + certBlock, _ := pem.Decode([]byte(certificate)) + if certBlock == nil { + return nil, fmt.Errorf("failed to decode pem") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse pem: %w", err) + } + + privkeyBlock, _ := pem.Decode([]byte(privateKey)) + if privkeyBlock == nil { + return nil, fmt.Errorf("failed to decode pem") + } + privkey, err := x509.ParsePKCS1PrivateKey(privkeyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse pem: %w", err) + } + + pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password) + if err != nil { + return nil, fmt.Errorf("failed to encode as pfx %w", err) + } + + return pfxData, nil +} diff --git a/internal/deployer/local.go b/internal/deployer/local.go index 7b6e224c..8e87d67c 100644 --- a/internal/deployer/local.go +++ b/internal/deployer/local.go @@ -51,10 +51,6 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { // 写入证书和私钥文件 switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", "pem") { - case "pfx": - // TODO: pfx - return fmt.Errorf("not implemented") - case "pem": if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil { return fmt.Errorf("failed to save certificate file: %w", err) @@ -67,6 +63,18 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { } d.infos = append(d.infos, toStr("保存私钥成功", nil)) + + case "pfx": + pfxData, err := convertPemToPfx(d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("pfxPassword")) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) } // 执行命令 @@ -87,15 +95,15 @@ func (d *LocalDeployer) execCommand(command string) (string, string, error) { var cmd *exec.Cmd switch d.option.DeployConfig.GetConfigAsString("shell") { + case "sh": + cmd = exec.Command("sh", "-c", command) + case "cmd": cmd = exec.Command("cmd", "/C", command) case "powershell": cmd = exec.Command("powershell", "-Command", command) - case "sh": - cmd = exec.Command("sh", "-c", command) - case "": if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", command) diff --git a/internal/deployer/ssh.go b/internal/deployer/ssh.go index e2f11c45..1a959cbb 100644 --- a/internal/deployer/ssh.go +++ b/internal/deployer/ssh.go @@ -60,20 +60,34 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { d.infos = append(d.infos, toStr("SSH 执行前置命令成功", stdout)) } - // 上传证书 - if err := d.uploadFile(client, d.option.Certificate.Certificate, d.option.DeployConfig.GetConfigAsString("certPath")); err != nil { - return fmt.Errorf("failed to upload certificate file: %w", err) + // 上传证书和私钥文件 + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", "pem") { + case "pem": + if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil { + return fmt.Errorf("failed to upload certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) + + if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil { + return fmt.Errorf("failed to upload private key file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil)) + + case "pfx": + pfxData, err := convertPemToPfx(d.option.Certificate.Certificate, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("pfxPassword")) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := d.writeSftpFile(client, d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil { + return fmt.Errorf("failed to upload certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) } - d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) - - // 上传私钥 - if err := d.uploadFile(client, d.option.Certificate.PrivateKey, d.option.DeployConfig.GetConfigAsString("keyPath")); err != nil { - return fmt.Errorf("failed to upload private key file: %w", err) - } - - d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil)) - // 执行命令 command := d.option.DeployConfig.GetConfigAsString("command") if command != "" { @@ -133,7 +147,11 @@ func (d *SSHDeployer) sshExecCommand(client *ssh.Client, command string) (string return stdoutBuf.String(), stderrBuf.String(), err } -func (d *SSHDeployer) uploadFile(client *ssh.Client, path string, content string) error { +func (d *SSHDeployer) writeSftpFileString(client *ssh.Client, path string, content string) error { + return d.writeSftpFile(client, path, []byte(content)) +} + +func (d *SSHDeployer) writeSftpFile(client *ssh.Client, path string, data []byte) error { sftpCli, err := sftp.NewClient(client) if err != nil { return fmt.Errorf("failed to create sftp client: %w", err) @@ -150,7 +168,7 @@ func (d *SSHDeployer) uploadFile(client *ssh.Client, path string, content string } defer file.Close() - _, err = file.Write([]byte(content)) + _, err = file.Write(data) if err != nil { return fmt.Errorf("failed to write to remote file: %w", err) } diff --git a/internal/pkg/utils/fs/fs.go b/internal/pkg/utils/fs/fs.go index a8db4c04..3ae82060 100644 --- a/internal/pkg/utils/fs/fs.go +++ b/internal/pkg/utils/fs/fs.go @@ -6,7 +6,7 @@ import ( "path/filepath" ) -// 与 `WriteFile` 类似,但写入的是字符串内容。 +// 与 [WriteFile] 类似,但写入的是字符串内容。 // // 入参: // - path: 文件路径。 diff --git a/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx b/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx index 9cb5e686..064df882 100644 --- a/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx +++ b/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx @@ -102,7 +102,7 @@ const DeployToHuaweiCloudCDN = () => { onValueChange={(value) => { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.resourceType = value?.trim(); + draft.config.resourceType = value; }); setDeploy(newData); }} diff --git a/ui/src/components/certimate/DeployToLocal.tsx b/ui/src/components/certimate/DeployToLocal.tsx index 2c147936..53346fa6 100644 --- a/ui/src/components/certimate/DeployToLocal.tsx +++ b/ui/src/components/certimate/DeployToLocal.tsx @@ -6,6 +6,7 @@ import { produce } from "immer"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useDeployEditContext } from "./DeployEdit"; import { cn } from "@/lib/utils"; @@ -20,8 +21,10 @@ const DeployToLocal = () => { setDeploy({ ...data, config: { + format: "pem", certPath: "/etc/nginx/ssl/nginx.crt", keyPath: "/etc/nginx/ssl/nginx.key", + pfxPassword: "", shell: "sh", preCommand: "", command: "sudo service nginx reload", @@ -34,15 +37,34 @@ const DeployToLocal = () => { setError({}); }, []); - const formSchema = z.object({ - certPath: z.string().min(1, t("domain.deployment.form.file_cert_path.placeholder")), - keyPath: z.string().min(1, t("domain.deployment.form.file_key_path.placeholder")), - shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], { - message: t("domain.deployment.form.shell.placeholder"), - }), - preCommand: z.string().optional(), - command: z.string().optional(), - }); + const formSchema = z + .object({ + format: z.union([z.literal("pem"), z.literal("pfx")], { + message: t("domain.deployment.form.file_format.placeholder"), + }), + certPath: z + .string() + .min(1, t("domain.deployment.form.file_cert_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + keyPath: z + .string() + .min(0, t("domain.deployment.form.file_key_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + pfxPassword: z.string().optional(), + shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], { + message: t("domain.deployment.form.shell.placeholder"), + }), + preCommand: z.string().optional(), + command: z.string().optional(), + }) + .refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), { + message: t("domain.deployment.form.file_key_path.placeholder"), + path: ["keyPath"], + }) + .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { + message: t("domain.deployment.form.file_pfx_password.placeholder"), + path: ["pfxPassword"], + }); useEffect(() => { const res = formSchema.safeParse(data.config); @@ -67,6 +89,26 @@ const DeployToLocal = () => { } }, [data]); + useEffect(() => { + if (data.config?.format === "pem") { + if (data.config.certPath && data.config.certPath.endsWith(".pfx")) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/.pfx$/, ".crt"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "pfx") { + if (data.config.certPath && data.config.certPath.endsWith(".crt")) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/.crt$/, ".pfx"); + }); + setDeploy(newData); + } + } + }, [data.config?.format]); + const getOptionCls = (val: string) => { if (data.config?.shell === val) { return "border-primary dark:border-primary"; @@ -78,6 +120,31 @@ const DeployToLocal = () => { return ( <>
+
+ + +
{error?.format}
+
+
{
{error?.certPath}
-
- - { - const newData = produce(data, (draft) => { - draft.config ??= {}; - draft.config.keyPath = e.target.value?.trim(); - }); - setDeploy(newData); - }} - /> -
{error?.keyPath}
-
+ {data.config?.format === "pem" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.keyPath}
+
+ ) : ( + <> + )} + + {data.config?.format === "pfx" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.pfxPassword = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.pfxPassword}
+
+ ) : ( + <> + )}
diff --git a/ui/src/components/certimate/DeployToSSH.tsx b/ui/src/components/certimate/DeployToSSH.tsx index edb869fe..78b3ec01 100644 --- a/ui/src/components/certimate/DeployToSSH.tsx +++ b/ui/src/components/certimate/DeployToSSH.tsx @@ -5,6 +5,7 @@ import { produce } from "immer"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useDeployEditContext } from "./DeployEdit"; @@ -18,8 +19,10 @@ const DeployToSSH = () => { setDeploy({ ...data, config: { + format: "pem", certPath: "/etc/nginx/ssl/nginx.crt", keyPath: "/etc/nginx/ssl/nginx.key", + pfxPassword: "", preCommand: "", command: "sudo service nginx reload", }, @@ -31,12 +34,31 @@ const DeployToSSH = () => { setError({}); }, []); - const formSchema = z.object({ - certPath: z.string().min(1, t("domain.deployment.form.file_cert_path.placeholder")), - keyPath: z.string().min(1, t("domain.deployment.form.file_key_path.placeholder")), - preCommand: z.string().optional(), - command: z.string().optional(), - }); + const formSchema = z + .object({ + format: z.union([z.literal("pem"), z.literal("pfx")], { + message: t("domain.deployment.form.file_format.placeholder"), + }), + certPath: z + .string() + .min(1, t("domain.deployment.form.file_cert_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + keyPath: z + .string() + .min(0, t("domain.deployment.form.file_key_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + pfxPassword: z.string().optional(), + preCommand: z.string().optional(), + command: z.string().optional(), + }) + .refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), { + message: t("domain.deployment.form.file_key_path.placeholder"), + path: ["keyPath"], + }) + .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { + message: t("domain.deployment.form.file_pfx_password.placeholder"), + path: ["pfxPassword"], + }); useEffect(() => { const res = formSchema.safeParse(data.config); @@ -59,9 +81,54 @@ const DeployToSSH = () => { } }, [data]); + useEffect(() => { + if (data.config?.format === "pem") { + if (data.config.certPath && data.config.certPath.endsWith(".pfx")) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/.pfx$/, ".crt"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "pfx") { + if (data.config.certPath && data.config.certPath.endsWith(".crt")) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/.crt$/, ".pfx"); + }); + setDeploy(newData); + } + } + }, [data.config?.format]); + return ( <>
+
+ + +
{error?.format}
+
+
{
{error?.certPath}
-
- - { - const newData = produce(data, (draft) => { - draft.config ??= {}; - draft.config.keyPath = e.target.value?.trim(); - }); - setDeploy(newData); - }} - /> -
{error?.keyPath}
-
+ {data.config?.format === "pem" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.keyPath}
+
+ ) : ( + <> + )} + + {data.config?.format === "pfx" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.pfxPassword = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.pfxPassword}
+
+ ) : ( + <> + )}
diff --git a/ui/src/i18n/locales/en/nls.domain.json b/ui/src/i18n/locales/en/nls.domain.json index 1a271ec4..596150db 100644 --- a/ui/src/i18n/locales/en/nls.domain.json +++ b/ui/src/i18n/locales/en/nls.domain.json @@ -86,10 +86,14 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "Please enter ELB loadbalancer ID", "domain.deployment.form.huaweicloud_elb_listener_id.label": "Listener ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "Please enter ELB listener ID", + "domain.deployment.form.file_format.label": "Certificate Format", + "domain.deployment.form.file_format.placeholder": "Please select certificate format", "domain.deployment.form.file_cert_path.label": "Certificate Save Path", "domain.deployment.form.file_cert_path.placeholder": "Please enter certificate save path", "domain.deployment.form.file_key_path.label": "Private Key Save Path", "domain.deployment.form.file_key_path.placeholder": "Please enter private key save path", + "domain.deployment.form.file_pfx_password.label": "PFX Output Password", + "domain.deployment.form.file_pfx_password.placeholder": "Please enter PFX output password", "domain.deployment.form.shell.label": "Shell", "domain.deployment.form.shell.placeholder": "Please select shell environment", "domain.deployment.form.shell.option.sh.label": "POSIX Bash (Linux)", diff --git a/ui/src/i18n/locales/zh/nls.domain.json b/ui/src/i18n/locales/zh/nls.domain.json index 10c53c9b..e5c65072 100644 --- a/ui/src/i18n/locales/zh/nls.domain.json +++ b/ui/src/i18n/locales/zh/nls.domain.json @@ -86,10 +86,14 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "请输入负载均衡器 ID(可从华为云控制面板获取)", "domain.deployment.form.huaweicloud_elb_listener_id.label": "监听器 ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "请输入监听器 ID(可从华为云控制面板获取)", - "domain.deployment.form.file_key_path.label": "私钥保存路径", - "domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径", + "domain.deployment.form.file_format.label": "证书格式", + "domain.deployment.form.file_format.placeholder": "请选择证书格式", "domain.deployment.form.file_cert_path.label": "证书保存路径", "domain.deployment.form.file_cert_path.placeholder": "请输入证书保存路径", + "domain.deployment.form.file_key_path.label": "私钥保存路径", + "domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径", + "domain.deployment.form.file_pfx_password.label": "PFX 导出密码", + "domain.deployment.form.file_pfx_password.placeholder": "请输入 PFX 导出密码", "domain.deployment.form.shell.label": "Shell", "domain.deployment.form.shell.placeholder": "请选择命令执行环境", "domain.deployment.form.shell_pre_command.label": "前置命令", diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index a792e756..ff655b98 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -209,7 +209,7 @@ const Dashboard = () => { {t("history.log")} -
+
{deployment.log.check && ( <> {deployment.log.check.map((item: Log) => { diff --git a/ui/src/pages/history/History.tsx b/ui/src/pages/history/History.tsx index 24de0c7b..e0a47186 100644 --- a/ui/src/pages/history/History.tsx +++ b/ui/src/pages/history/History.tsx @@ -104,7 +104,7 @@ const History = () => { {t("history.log")} -
+
{deployment.log.check && ( <> {deployment.log.check.map((item: Log) => { From 0396d8222edf14a980b7cc6ab35265894a4b9ace Mon Sep 17 00:00:00 2001 From: Leo Chen Date: Sun, 27 Oct 2024 20:21:34 +0800 Subject: [PATCH 03/11] feat: add mail push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增电子邮箱推送 --- internal/domain/notify.go | 1 + internal/notify/mail.go | 58 +++++ internal/notify/notify.go | 10 + ui/src/components/notify/Mail.tsx | 319 +++++++++++++++++++++++ ui/src/domain/settings.ts | 13 +- ui/src/i18n/locales/en/nls.common.json | 3 +- ui/src/i18n/locales/en/nls.settings.json | 6 + ui/src/i18n/locales/zh/nls.common.json | 3 +- ui/src/i18n/locales/zh/nls.settings.json | 6 + ui/src/pages/setting/Notify.tsx | 7 + 10 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 internal/notify/mail.go create mode 100644 ui/src/components/notify/Mail.tsx diff --git a/internal/domain/notify.go b/internal/domain/notify.go index de9cf6cf..534d8b02 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -6,6 +6,7 @@ const ( NotifyChannelTelegram = "telegram" NotifyChannelLark = "lark" NotifyChannelServerChan = "serverchan" + NotifyChannelMail = "mail" ) type NotifyTestPushReq struct { diff --git a/internal/notify/mail.go b/internal/notify/mail.go new file mode 100644 index 00000000..ece00ad2 --- /dev/null +++ b/internal/notify/mail.go @@ -0,0 +1,58 @@ +package notify + +import ( + "context" + "net/smtp" +) + +type Mail struct { + senderAddress string + smtpHostAddr string + smtpHostPort string + smtpAuth smtp.Auth + receiverAddresses string +} + +func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort string) *Mail { + if(smtpHostPort == "") { + smtpHostPort = "25" + } + + return &Mail{ + senderAddress: senderAddress, + smtpHostAddr: smtpHostAddr, + smtpHostPort: smtpHostPort, + receiverAddresses: receiverAddresses, + } +} + +func (m *Mail) SetAuth(username, password string) { + m.smtpAuth = smtp.PlainAuth("", username, password, m.smtpHostAddr) +} + +func (m *Mail) Send(ctx context.Context, subject, message string) error { + // 构建邮件 + from := m.senderAddress + to := []string{m.receiverAddresses} + msg := []byte( + "From: " + from + "\r\n" + + "To: " + m.receiverAddresses + "\r\n" + + "Subject: " + subject + "\r\n" + + "\r\n" + + message + "\r\n") + + var smtpAddress string + // 组装邮箱服务器地址 + if(m.smtpHostPort == "25"){ + smtpAddress = m.smtpHostAddr + }else{ + smtpAddress = m.smtpHostAddr + ":" + m.smtpHostPort + } + + err := smtp.SendMail(smtpAddress, m.smtpAuth, from, to, msg) + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 3dfa643b..4b91cdbd 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -106,6 +106,8 @@ func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, e return getWebhookNotifier(conf), nil case domain.NotifyChannelServerChan: return getServerChanNotifier(conf), nil + case domain.NotifyChannelMail: + return getMailNotifier(conf), nil } return nil, fmt.Errorf("notifier not found") @@ -166,6 +168,14 @@ func getLarkNotifier(conf map[string]any) notifyPackage.Notifier { return lark.NewWebhookService(getString(conf, "webhookUrl")) } +func getMailNotifier(conf map[string]any) notifyPackage.Notifier { + rs := NewMail(getString(conf, "senderAddress"),getString(conf,"receiverAddress"), getString(conf, "smtpHostAddr"), getString(conf, "smtpHostPort")) + + rs.SetAuth(getString(conf, "username"), getString(conf, "password")) + + return rs +} + func getString(conf map[string]any, key string) string { if _, ok := conf[key]; !ok { return "" diff --git a/ui/src/components/notify/Mail.tsx b/ui/src/components/notify/Mail.tsx new file mode 100644 index 00000000..9161ba09 --- /dev/null +++ b/ui/src/components/notify/Mail.tsx @@ -0,0 +1,319 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { getErrMessage } from "@/lib/error"; +import { NotifyChannelMail, NotifyChannels } from "@/domain/settings"; +import { useNotifyContext } from "@/providers/notify"; +import { update } from "@/repository/settings"; +import Show from "@/components/Show"; +import { notifyTest } from "@/api/notify"; + +type MailSetting = { + id: string; + name: string; + data: NotifyChannelMail; +}; + +const Mail = () => { + const { config, setChannels } = useNotifyContext(); + const { t } = useTranslation(); + + const [changed, setChanged] = useState(false); + + const [mail, setmail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }, + }); + + const [originMail, setoriginMail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }, + }); + + useEffect(() => { + setChanged(false); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setoriginMail({ + id: config.id ?? "", + name: "mail", + data, + }); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setmail({ + id: config.id ?? "", + name: "mail", + data, + }); + }, [config]); + + const { toast } = useToast(); + + const getDetailMail = () => { + const df: NotifyChannelMail = { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }; + if (!config.content) { + return df; + } + const chanels = config.content as NotifyChannels; + if (!chanels.mail) { + return df; + } + + return chanels.mail as NotifyChannelMail; + }; + + const checkChanged = (data: NotifyChannelMail) => { + if (data.senderAddress !== originMail.data.senderAddress || data.receiverAddresses !== originMail.data.receiverAddresses || data.smtpHostAddr !== originMail.data.smtpHostAddr || data.smtpHostPort !== originMail.data.smtpHostPort || data.username !== originMail.data.username || data.password !== originMail.data.password) { + setChanged(true); + } else { + setChanged(false); + } + }; + + const handleSaveClick = async () => { + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + mail: { + ...mail.data, + }, + }, + }); + + setChannels(resp); + toast({ + title: t("common.save.succeeded.message"), + description: t("settings.notification.config.saved.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + const handlePushTestClick = async () => { + try { + await notifyTest("mail"); + + toast({ + title: t("settings.notification.config.push.test.message.success.message"), + description: t("settings.notification.config.push.test.message.success.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("settings.notification.config.push.test.message.failed.message"), + description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + const handleSwitchChange = async () => { + const newData = { + ...mail, + data: { + ...mail.data, + enabled: !mail.data.enabled, + }, + }; + setmail(newData); + + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + mail: { + ...newData.data, + }, + }, + }); + + setChannels(resp); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + return ( +
+ { + const newData = { + ...mail, + data: { + ...mail.data, + senderAddress: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> +
+ + +
+ +
+ + + + + + + +
+
+ ); +}; + +export default Mail; diff --git a/ui/src/domain/settings.ts b/ui/src/domain/settings.ts index af77ea98..798c4e86 100644 --- a/ui/src/domain/settings.ts +++ b/ui/src/domain/settings.ts @@ -23,9 +23,10 @@ export type NotifyChannels = { telegram?: NotifyChannel; webhook?: NotifyChannel; serverchan?: NotifyChannel; + mail?: NotifyChannelMail; }; -export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan; +export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan | NotifyChannelMail; export type NotifyChannelDingTalk = { accessToken: string; @@ -54,6 +55,16 @@ export type NotifyChannelServerChan = { enabled: boolean; }; +export type NotifyChannelMail = { + senderAddress: string; + receiverAddresses: string; + smtpHostAddr: string; + smtpHostPort: string; + username:string; + password:string; + enabled: boolean; +}; + export const defaultNotifyTemplate: NotifyTemplate = { title: "您有 {COUNT} 张证书即将过期", content: "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!", diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json index d90008ac..6870c616 100644 --- a/ui/src/i18n/locales/en/nls.common.json +++ b/ui/src/i18n/locales/en/nls.common.json @@ -85,5 +85,6 @@ "common.provider.kubernetes.secret": "Kubernetes - Secret", "common.provider.dingtalk": "DingTalk", "common.provider.telegram": "Telegram", - "common.provider.lark": "Lark" + "common.provider.lark": "Lark", + "common.provider.mail": "Mail" } diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json index b23f6ee0..fd84b888 100644 --- a/ui/src/i18n/locales/en/nls.settings.json +++ b/ui/src/i18n/locales/en/nls.settings.json @@ -36,6 +36,12 @@ "settings.notification.dingtalk.secret.placeholder": "Signature for signed addition", "settings.notification.url.errmsg.invalid": "Invalid Url format", "settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send", + "settings.notification.mail.sender_address.placeholder": "Sender email address", + "settings.notification.mail.receiver_address.placeholder": "Receiver email address", + "settings.notification.mail.smtp_host.placeholder": "SMTP server address", + "settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25", + "settings.notification.mail.username.placeholder": "username", + "settings.notification.mail.password.placeholder": "password", "settings.ca.tab": "Certificate Authority", "settings.ca.provider.errmsg.empty": "Please select a Certificate Authority", diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json index d1144d79..12fcd58a 100644 --- a/ui/src/i18n/locales/zh/nls.common.json +++ b/ui/src/i18n/locales/zh/nls.common.json @@ -85,6 +85,7 @@ "common.provider.kubernetes.secret": "Kubernetes - Secret", "common.provider.dingtalk": "钉钉", "common.provider.telegram": "Telegram", - "common.provider.lark": "飞书" + "common.provider.lark": "飞书", + "common.provider.mail": "电子邮件" } diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json index f3dedaa9..2c0a807f 100644 --- a/ui/src/i18n/locales/zh/nls.settings.json +++ b/ui/src/i18n/locales/zh/nls.settings.json @@ -36,6 +36,12 @@ "settings.notification.dingtalk.secret.placeholder": "加签的签名", "settings.notification.url.errmsg.invalid": "URL 格式不正确", "settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send", + "settings.notification.mail.sender_address.placeholder": "发送邮箱地址", + "settings.notification.mail.receiver_address.placeholder": "接收邮箱地址", + "settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址", + "settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25", + "settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名", + "settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码", "settings.ca.tab": "证书颁发机构(CA)", "settings.ca.provider.errmsg.empty": "请选择证书分发机构", diff --git a/ui/src/pages/setting/Notify.tsx b/ui/src/pages/setting/Notify.tsx index ad06a082..350e4b8b 100644 --- a/ui/src/pages/setting/Notify.tsx +++ b/ui/src/pages/setting/Notify.tsx @@ -7,6 +7,7 @@ import NotifyTemplate from "@/components/notify/NotifyTemplate"; import Telegram from "@/components/notify/Telegram"; import Webhook from "@/components/notify/Webhook"; import ServerChan from "@/components/notify/ServerChan"; +import Mail from "@/components/notify/Mail"; import { NotifyProvider } from "@/providers/notify"; const Notify = () => { @@ -61,6 +62,12 @@ const Notify = () => { + + {t("common.provider.mail")} + + + +
From c9f347f77af04aeb41b125afdde0a82f382bc445 Mon Sep 17 00:00:00 2001 From: Leo Chen Date: Sun, 27 Oct 2024 20:27:46 +0800 Subject: [PATCH 04/11] fix mail push onchange --- ui/src/components/notify/Mail.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/components/notify/Mail.tsx b/ui/src/components/notify/Mail.tsx index 9161ba09..7b6195fb 100644 --- a/ui/src/components/notify/Mail.tsx +++ b/ui/src/components/notify/Mail.tsx @@ -214,7 +214,7 @@ const Mail = () => { ...mail, data: { ...mail.data, - secret: e.target.value, + receiverAddresses: e.target.value, }, }; checkChanged(newData.data); @@ -230,7 +230,7 @@ const Mail = () => { ...mail, data: { ...mail.data, - secret: e.target.value, + smtpHostAddr: e.target.value, }, }; checkChanged(newData.data); @@ -246,7 +246,7 @@ const Mail = () => { ...mail, data: { ...mail.data, - secret: e.target.value, + smtpHostPort: e.target.value, }, }; checkChanged(newData.data); @@ -262,7 +262,7 @@ const Mail = () => { ...mail, data: { ...mail.data, - secret: e.target.value, + username: e.target.value, }, }; checkChanged(newData.data); @@ -278,7 +278,7 @@ const Mail = () => { ...mail, data: { ...mail.data, - secret: e.target.value, + password: e.target.value, }, }; checkChanged(newData.data); From 6d7a91f49ba83794af9c37f7c0ebbf62917c96e8 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 27 Oct 2024 20:44:38 +0800 Subject: [PATCH 05/11] refactor: clean code --- go.mod | 2 +- go.sum | 1 - internal/deployer/deployer.go | 34 ++++++--------------------------- internal/pkg/utils/x509/x509.go | 29 ++++++++++++++++++++++++++-- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index d846826f..b37a07ff 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 golang.org/x/crypto v0.28.0 k8s.io/api v0.31.1 k8s.io/apimachinery v0.31.1 @@ -59,7 +60,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 // indirect github.com/x448/float16 v0.8.4 // indirect go.mongodb.org/mongo-driver v1.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index fdcb5b6f..afa71518 100644 --- a/go.sum +++ b/go.sum @@ -458,7 +458,6 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 h1:Oymmfm github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017/go.mod h1:gnLxGXlLmF+jDqWR1/RVoF/UUwxQxomQhkc0oN7KeuI= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.992/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017 h1:SXrldOXwgomYuATVAuz5ofpTjB+99qVELgdy5R5kMgI= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 h1:kwiUoCkooUgy7iPyhEEbio7WT21kGJUeZ5JeJfb/dYk= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 37824cab..c00d9ff3 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -2,18 +2,16 @@ package deployer import ( "context" - "crypto/x509" "encoding/json" - "encoding/pem" "errors" "fmt" - "strings" "github.com/pocketbase/pocketbase/models" "software.sslmate.com/src/go-pkcs12" "github.com/usual2970/certimate/internal/applicant" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/x509" "github.com/usual2970/certimate/internal/utils/app" ) @@ -41,7 +39,6 @@ const ( type DeployerOption struct { DomainId string `json:"domainId"` Domain string `json:"domain"` - Product string `json:"product"` Access string `json:"access"` AccessRecord *models.Record `json:"-"` DeployConfig domain.DeployConfig `json:"deployConfig"` @@ -93,7 +90,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep option := &DeployerOption{ DomainId: record.Id, Domain: record.GetString("domain"), - Product: getProduct(deployConfig.Type), Access: access.GetString("config"), AccessRecord: access, DeployConfig: deployConfig, @@ -121,7 +117,7 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep case targetAliyunNLB: return NewAliyunNLBDeployer(option) case targetTencentCDN: - return NewTencentCDNDeployer(option) + return NewTencentCDNDeployer(option) case targetTencentECDN: return NewTencentECDNDeployer(option) case targetTencentCLB: @@ -148,14 +144,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep return nil, errors.New("unsupported deploy target") } -func getProduct(t string) string { - rs := strings.Split(t, "-") - if len(rs) < 2 { - return "" - } - return rs[1] -} - func toStr(tag string, data any) string { if data == nil { return tag @@ -200,24 +188,14 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string { } func convertPemToPfx(certificate string, privateKey string, password string) ([]byte, error) { - // TODO: refactor - - certBlock, _ := pem.Decode([]byte(certificate)) - if certBlock == nil { - return nil, fmt.Errorf("failed to decode pem") - } - cert, err := x509.ParseCertificate(certBlock.Bytes) + cert, err := x509.ParseCertificateFromPEM(certificate) if err != nil { - return nil, fmt.Errorf("failed to parse pem: %w", err) + return nil, err } - privkeyBlock, _ := pem.Decode([]byte(privateKey)) - if privkeyBlock == nil { - return nil, fmt.Errorf("failed to decode pem") - } - privkey, err := x509.ParsePKCS1PrivateKey(privkeyBlock.Bytes) + privkey, err := x509.ParsePKCS1PrivateKeyFromPEM(privateKey) if err != nil { - return nil, fmt.Errorf("failed to parse pem: %w", err) + return nil, err } pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password) diff --git a/internal/pkg/utils/x509/x509.go b/internal/pkg/utils/x509/x509.go index 09d67d3a..40cc39d6 100644 --- a/internal/pkg/utils/x509/x509.go +++ b/internal/pkg/utils/x509/x509.go @@ -2,6 +2,7 @@ import ( "crypto/ecdsa" + "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" @@ -48,7 +49,7 @@ func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error) return cert, nil } -// 从 PEM 编码的私钥字符串解析并返回一个 ECDSA 私钥对象。 +// 从 PEM 编码的私钥字符串解析并返回一个 ecdsa.PrivateKey 对象。 // // 入参: // - privkeyPem: 私钥 PEM 内容。 @@ -72,7 +73,31 @@ func ParseECPrivateKeyFromPEM(privkeyPem string) (privkey *ecdsa.PrivateKey, err return privkey, nil } -// 将 ECDSA 私钥转换为 PEM 编码的字符串。 +// 从 PEM 编码的私钥字符串解析并返回一个 rsa.PrivateKey 对象。 +// +// 入参: +// - privkeyPem: 私钥 PEM 内容。 +// +// 出参: +// - privkey: rsa.PrivateKey 对象。 +// - err: 错误。 +func ParsePKCS1PrivateKeyFromPEM(privkeyPem string) (privkey *rsa.PrivateKey, err error) { + pemData := []byte(privkeyPem) + + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + privkey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + return privkey, nil +} + +// 将 ecdsa.PrivateKey 对象转换为 PEM 编码的字符串。 // // 入参: // - privkey: ecdsa.PrivateKey 对象。 From 009e8fb976f89713265d091aab4b590ac216017c Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 27 Oct 2024 21:10:19 +0800 Subject: [PATCH 06/11] feat: preset scripts on deployment to local --- ui/src/components/certimate/DeployToLocal.tsx | 72 ++++++++++++++++++- ui/src/i18n/locales/en/nls.domain.json | 3 + ui/src/i18n/locales/zh/nls.domain.json | 3 + 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/ui/src/components/certimate/DeployToLocal.tsx b/ui/src/components/certimate/DeployToLocal.tsx index 53346fa6..9f7fffd1 100644 --- a/ui/src/components/certimate/DeployToLocal.tsx +++ b/ui/src/components/certimate/DeployToLocal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { z } from "zod"; import { produce } from "immer"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; @@ -117,6 +118,60 @@ const DeployToLocal = () => { return ""; }; + const handleUsePresetScript = (key: string) => { + switch (key) { + case "reload_nginx": + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = "sh"; + draft.config.command = "sudo service nginx reload"; + }); + setDeploy(newData); + } + break; + + case "binding_iis": + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = "powershell"; + draft.config.command = ` +# 请将以下变量替换为实际值 +$pfxPath = "" # PFX 文件路径 +$pfxPassword = "" # PFX 密码 +$siteName = "" # IIS 网站名称 +$domain = "" # 域名 +$ipaddr = "" # 绑定 IP,“*”表示所有 IP 绑定 +$port = "" # 绑定端口 + + +# 导入证书到本地计算机的个人存储区 +$cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable +# 获取 Thumbprint +$thumbprint = $cert.Thumbprint +# 导入 WebAdministration 模块 +Import-Module WebAdministration +# 检查是否已存在 HTTPS 绑定 +$existingBinding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -HostHeader "$domain" -ErrorAction SilentlyContinue +if (!$existingBinding) { + # 添加新的 HTTPS 绑定 + New-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain" +} +# 获取绑定对象 +$binding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain" +# 绑定 SSL 证书 +$binding.AddSslCertificate($thumbprint, "My") +# 删除目录下的证书文件 +Remove-Item -Path "$pfxPath" -Force + `.trim(); + }); + setDeploy(newData); + } + break; + } + }; + return ( <>
@@ -263,7 +318,22 @@ const DeployToLocal = () => {
- +
+ + + + {t("domain.deployment.form.shell_preset_scripts.trigger")} + + + handleUsePresetScript("reload_nginx")}> + {t("domain.deployment.form.shell_preset_scripts.option.reload_nginx.label")} + + handleUsePresetScript("binding_iis")}> + {t("domain.deployment.form.shell_preset_scripts.option.binding_iis.label")} + + + +