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) => {