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 名称",