From b47a1a13cbfcff6bdf6aefe37308950a99568460 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 28 Oct 2024 11:49:44 +0800 Subject: [PATCH] feat: support jks format --- go.mod | 1 + go.sum | 2 + internal/deployer/deployer.go | 41 ++++++- internal/deployer/local.go | 48 ++++++-- internal/deployer/ssh.go | 31 +++++- ui/src/components/certimate/DeployToLocal.tsx | 104 +++++++++++++++++- ui/src/components/certimate/DeployToSSH.tsx | 104 +++++++++++++++++- ui/src/i18n/locales/en/nls.domain.json | 6 + ui/src/i18n/locales/zh/nls.domain.json | 6 + 9 files changed, 321 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index b37a07ff..7e272b97 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.114 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/nikoksr/notify v1.0.0 + github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 github.com/pkg/sftp v1.13.6 github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/pocketbase v0.22.18 diff --git a/go.sum b/go.sum index afa71518..2e3575b4 100644 --- a/go.sum +++ b/go.sum @@ -398,6 +398,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index c00d9ff3..9dc537f3 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -1,11 +1,15 @@ package deployer import ( + "bytes" "context" "encoding/json" + "encoding/pem" "errors" "fmt" + "time" + "github.com/pavlo-v-chernykh/keystore-go/v4" "github.com/pocketbase/pocketbase/models" "software.sslmate.com/src/go-pkcs12" @@ -187,7 +191,7 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string { return rs } -func convertPemToPfx(certificate string, privateKey string, password string) ([]byte, error) { +func convertPEMToPFX(certificate string, privateKey string, password string) ([]byte, error) { cert, err := x509.ParseCertificateFromPEM(certificate) if err != nil { return nil, err @@ -205,3 +209,38 @@ func convertPemToPfx(certificate string, privateKey string, password string) ([] return pfxData, nil } + +func convertPEMToJKS(certificate string, privateKey string, alias string, keypass string, storepass string) ([]byte, error) { + certBlock, _ := pem.Decode([]byte(certificate)) + if certBlock == nil { + return nil, errors.New("failed to decode certificate PEM") + } + + privkeyBlock, _ := pem.Decode([]byte(privateKey)) + if privkeyBlock == nil { + return nil, errors.New("failed to decode private key PEM") + } + + ks := keystore.New() + entry := keystore.PrivateKeyEntry{ + CreationTime: time.Now(), + PrivateKey: privkeyBlock.Bytes, + CertificateChain: []keystore.Certificate{ + { + Type: "X509", + Content: certBlock.Bytes, + }, + }, + } + + if err := ks.SetPrivateKeyEntry(alias, entry, []byte(keypass)); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := ks.Store(&buf, []byte(storepass)); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/deployer/local.go b/internal/deployer/local.go index 8e87d67c..0c839fa4 100644 --- a/internal/deployer/local.go +++ b/internal/deployer/local.go @@ -17,6 +17,18 @@ type LocalDeployer struct { infos []string } +const ( + certFormatPEM = "pem" + certFormatPFX = "pfx" + certFormatJKS = "jks" +) + +const ( + shellEnvSh = "sh" + shellEnvCmd = "cmd" + shellEnvPowershell = "powershell" +) + func NewLocalDeployer(option *DeployerOption) (Deployer, error) { return &LocalDeployer{ option: option, @@ -50,8 +62,8 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { } // 写入证书和私钥文件 - switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", "pem") { - case "pem": + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) { + case certFormatPEM: 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) } @@ -64,8 +76,12 @@ 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")) + case certFormatPFX: + 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) } @@ -74,6 +90,24 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { return fmt.Errorf("failed to save certificate file: %w", err) } + d.infos = append(d.infos, toStr("保存证书成功", nil)) + + case certFormatJKS: + jksData, err := convertPEMToJKS( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("jksAlias"), + d.option.DeployConfig.GetConfigAsString("jksKeypass"), + d.option.DeployConfig.GetConfigAsString("jksStorepass"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + d.infos = append(d.infos, toStr("保存证书成功", nil)) } @@ -95,13 +129,13 @@ func (d *LocalDeployer) execCommand(command string) (string, string, error) { var cmd *exec.Cmd switch d.option.DeployConfig.GetConfigAsString("shell") { - case "sh": + case shellEnvSh: cmd = exec.Command("sh", "-c", command) - case "cmd": + case shellEnvCmd: cmd = exec.Command("cmd", "/C", command) - case "powershell": + case shellEnvPowershell: cmd = exec.Command("powershell", "-Command", command) case "": diff --git a/internal/deployer/ssh.go b/internal/deployer/ssh.go index 1a959cbb..98c4de48 100644 --- a/internal/deployer/ssh.go +++ b/internal/deployer/ssh.go @@ -12,6 +12,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/fs" ) type SSHDeployer struct { @@ -61,8 +62,8 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { } // 上传证书和私钥文件 - switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", "pem") { - case "pem": + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) { + case certFormatPEM: 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) } @@ -75,8 +76,12 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { 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")) + case certFormatPFX: + 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) } @@ -86,6 +91,24 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { } d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) + + case certFormatJKS: + jksData, err := convertPEMToJKS( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("jksAlias"), + d.option.DeployConfig.GetConfigAsString("jksKeypass"), + d.option.DeployConfig.GetConfigAsString("jksStorepass"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) } // 执行命令 diff --git a/ui/src/components/certimate/DeployToLocal.tsx b/ui/src/components/certimate/DeployToLocal.tsx index 9f7fffd1..672cf52c 100644 --- a/ui/src/components/certimate/DeployToLocal.tsx +++ b/ui/src/components/certimate/DeployToLocal.tsx @@ -26,6 +26,9 @@ const DeployToLocal = () => { certPath: "/etc/nginx/ssl/nginx.crt", keyPath: "/etc/nginx/ssl/nginx.key", pfxPassword: "", + jksAlias: "", + jksKeypass: "", + jksStorepass: "", shell: "sh", preCommand: "", command: "sudo service nginx reload", @@ -40,7 +43,7 @@ const DeployToLocal = () => { const formSchema = z .object({ - format: z.union([z.literal("pem"), z.literal("pfx")], { + format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], { message: t("domain.deployment.form.file_format.placeholder"), }), certPath: z @@ -52,6 +55,9 @@ const DeployToLocal = () => { .min(0, t("domain.deployment.form.file_key_path.placeholder")) .max(255, t("common.errmsg.string_max", { max: 255 })), pfxPassword: z.string().optional(), + jksAlias: z.string().optional(), + jksKeypass: z.string().optional(), + jksStorepass: z.string().optional(), shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], { message: t("domain.deployment.form.shell.placeholder"), }), @@ -65,6 +71,18 @@ const DeployToLocal = () => { .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { message: t("domain.deployment.form.file_pfx_password.placeholder"), path: ["pfxPassword"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), { + message: t("domain.deployment.form.file_jks_alias.placeholder"), + path: ["jksAlias"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), { + message: t("domain.deployment.form.file_jks_keypass.placeholder"), + path: ["jksKeypass"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), { + message: t("domain.deployment.form.file_jks_storepass.placeholder"), + path: ["jksStorepass"], }); useEffect(() => { @@ -72,8 +90,13 @@ const DeployToLocal = () => { if (!res.success) { setError({ ...error, + format: res.error.errors.find((e) => e.path[0] === "format")?.message, certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message, + jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message, + jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message, + jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.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, @@ -81,8 +104,13 @@ const DeployToLocal = () => { } else { setError({ ...error, + format: undefined, certPath: undefined, keyPath: undefined, + pfxPassword: undefined, + jksAlias: undefined, + jksKeypass: undefined, + jksStorepass: undefined, shell: undefined, preCommand: undefined, command: undefined, @@ -92,18 +120,26 @@ const DeployToLocal = () => { useEffect(() => { if (data.config?.format === "pem") { - if (data.config.certPath && data.config.certPath.endsWith(".pfx")) { + if (/(.pfx|.jks)$/.test(data.config.certPath)) { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.certPath = data.config!.certPath.replace(/.pfx$/, ".crt"); + draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt"); }); setDeploy(newData); } } else if (data.config?.format === "pfx") { - if (data.config.certPath && data.config.certPath.endsWith(".crt")) { + if (/(.crt|.jks)$/.test(data.config.certPath)) { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.certPath = data.config!.certPath.replace(/.crt$/, ".pfx"); + draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "jks") { + if (/(.crt|.pfx)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks"); }); setDeploy(newData); } @@ -194,6 +230,7 @@ Remove-Item -Path "$pfxPath" -Force PEM PFX + JKS @@ -259,6 +296,63 @@ Remove-Item -Path "$pfxPath" -Force <> )} + {data.config?.format === "jks" ? ( + <> +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksAlias = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksAlias}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksKeypass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksKeypass}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksStorepass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksStorepass}
+
+ + ) : ( + <> + )} +
{ certPath: "/etc/nginx/ssl/nginx.crt", keyPath: "/etc/nginx/ssl/nginx.key", pfxPassword: "", + jksAlias: "", + jksKeypass: "", + jksStorepass: "", preCommand: "", command: "sudo service nginx reload", }, @@ -36,7 +39,7 @@ const DeployToSSH = () => { const formSchema = z .object({ - format: z.union([z.literal("pem"), z.literal("pfx")], { + format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], { message: t("domain.deployment.form.file_format.placeholder"), }), certPath: z @@ -48,6 +51,9 @@ const DeployToSSH = () => { .min(0, t("domain.deployment.form.file_key_path.placeholder")) .max(255, t("common.errmsg.string_max", { max: 255 })), pfxPassword: z.string().optional(), + jksAlias: z.string().optional(), + jksKeypass: z.string().optional(), + jksStorepass: z.string().optional(), preCommand: z.string().optional(), command: z.string().optional(), }) @@ -58,6 +64,18 @@ const DeployToSSH = () => { .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { message: t("domain.deployment.form.file_pfx_password.placeholder"), path: ["pfxPassword"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), { + message: t("domain.deployment.form.file_jks_alias.placeholder"), + path: ["jksAlias"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), { + message: t("domain.deployment.form.file_jks_keypass.placeholder"), + path: ["jksKeypass"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), { + message: t("domain.deployment.form.file_jks_storepass.placeholder"), + path: ["jksStorepass"], }); useEffect(() => { @@ -65,16 +83,26 @@ const DeployToSSH = () => { if (!res.success) { setError({ ...error, + format: res.error.errors.find((e) => e.path[0] === "format")?.message, certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message, + jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message, + jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message, + jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.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, + format: undefined, certPath: undefined, keyPath: undefined, + pfxPassword: undefined, + jksAlias: undefined, + jksKeypass: undefined, + jksStorepass: undefined, preCommand: undefined, command: undefined, }); @@ -83,18 +111,26 @@ const DeployToSSH = () => { useEffect(() => { if (data.config?.format === "pem") { - if (data.config.certPath && data.config.certPath.endsWith(".pfx")) { + if (/(.pfx|.jks)$/.test(data.config.certPath)) { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.certPath = data.config!.certPath.replace(/.pfx$/, ".crt"); + draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt"); }); setDeploy(newData); } } else if (data.config?.format === "pfx") { - if (data.config.certPath && data.config.certPath.endsWith(".crt")) { + if (/(.crt|.jks)$/.test(data.config.certPath)) { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.certPath = data.config!.certPath.replace(/.crt$/, ".pfx"); + draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "jks") { + if (/(.crt|.pfx)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks"); }); setDeploy(newData); } @@ -123,6 +159,7 @@ const DeployToSSH = () => { PEM PFX + JKS @@ -188,6 +225,63 @@ const DeployToSSH = () => { <> )} + {data.config?.format === "jks" ? ( + <> +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksAlias = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksAlias}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksKeypass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksKeypass}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksStorepass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksStorepass}
+
+ + ) : ( + <> + )} +