From 6367785b1bc203ee15f82d13e6af167a0c2ca63c Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 19 Nov 2024 19:09:48 +0800 Subject: [PATCH] feat: implement local, ssh, webhook `Deployer` --- internal/domain/access.go | 10 +- internal/pkg/core/deployer/logger.go | 107 ++++++++ .../core/deployer/providers/local/define.go | 17 ++ .../core/deployer/providers/local/local.go | 177 ++++++++++++ .../pkg/core/deployer/providers/ssh/define.go | 9 + .../pkg/core/deployer/providers/ssh/ssh.go | 251 ++++++++++++++++++ .../deployer/providers/webhook/webhook.go | 81 ++++++ 7 files changed, 647 insertions(+), 5 deletions(-) create mode 100644 internal/pkg/core/deployer/logger.go create mode 100644 internal/pkg/core/deployer/providers/local/define.go create mode 100644 internal/pkg/core/deployer/providers/local/local.go create mode 100644 internal/pkg/core/deployer/providers/ssh/define.go create mode 100644 internal/pkg/core/deployer/providers/ssh/ssh.go create mode 100644 internal/pkg/core/deployer/providers/webhook/webhook.go diff --git a/internal/domain/access.go b/internal/domain/access.go index 98716ff4..8229ca5f 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -6,8 +6,8 @@ type AliyunAccess struct { } type ByteplusAccess struct { - AccessKey string - SecretKey string + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` } type TencentAccess struct { @@ -27,9 +27,9 @@ type BaiduCloudAccess struct { } type AwsAccess struct { - Region string `json:"region"` AccessKeyId string `json:"accessKeyId"` SecretAccessKey string `json:"secretAccessKey"` + Region string `json:"region"` HostedZoneId string `json:"hostedZoneId"` } @@ -62,8 +62,8 @@ type PdnsAccess struct { } type VolcengineAccess struct { - AccessKeyID string - SecretAccessKey string + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` } type HttpreqAccess struct { diff --git a/internal/pkg/core/deployer/logger.go b/internal/pkg/core/deployer/logger.go new file mode 100644 index 00000000..a97800b1 --- /dev/null +++ b/internal/pkg/core/deployer/logger.go @@ -0,0 +1,107 @@ +package deployer + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// 表示定义证书部署器的日志记录器的抽象类型接口。 +type Logger interface { + // 追加一条日志记录。 + // 该方法会将 `data` 以 JSON 序列化后拼接到 `tag` 结尾。 + // + // 入参: + // - tag:标签。 + // - data:数据。 + Appendt(tag string, data ...any) + + // 追加一条日志记录。 + // 该方法会将 `args` 以 `format` 格式化。 + // + // 入参: + // - format:格式化字符串。 + // - args:格式化参数。 + Appendf(format string, args ...any) + + // 获取所有日志记录。 + GetRecords() []string +} + +// 表示默认的日志记录器类型。 +type DefaultLogger struct { + records []string +} + +var _ Logger = (*DefaultLogger)(nil) + +func (l *DefaultLogger) Appendt(tag string, data ...any) { + l.ensureInitialized() + + temp := make([]string, len(data)+1) + temp[0] = tag + for i, v := range data { + s := "" + if v != nil { + switch reflect.ValueOf(v).Kind() { + case reflect.String: + s = v.(string) + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + s = fmt.Sprintf("%v", v) + default: + jsonData, _ := json.Marshal(v) + s = string(jsonData) + } + } + + temp[i+1] = s + } + + l.records = append(l.records, strings.Join(temp, ": ")) +} + +func (l *DefaultLogger) Appendf(format string, args ...any) { + l.ensureInitialized() + + l.records = append(l.records, fmt.Sprintf(format, args...)) +} + +func (l *DefaultLogger) GetRecords() []string { + l.ensureInitialized() + + temp := make([]string, len(l.records)) + copy(temp, l.records) + return temp +} + +func (l *DefaultLogger) ensureInitialized() { + if l.records == nil { + l.records = make([]string, 0) + } +} + +func NewDefaultLogger() *DefaultLogger { + return &DefaultLogger{ + records: make([]string, 0), + } +} + +// 表示空的日志记录器类型。 +// 该日志记录器不会执行任何操作。 +type NilLogger struct{} + +var _ Logger = (*NilLogger)(nil) + +func (l *NilLogger) Appendt(string, ...any) {} +func (l *NilLogger) Appendf(string, ...any) {} +func (l *NilLogger) GetRecords() []string { + return make([]string, 0) +} + +func NewNilLogger() *NilLogger { + return &NilLogger{} +} diff --git a/internal/pkg/core/deployer/providers/local/define.go b/internal/pkg/core/deployer/providers/local/define.go new file mode 100644 index 00000000..5b3118d8 --- /dev/null +++ b/internal/pkg/core/deployer/providers/local/define.go @@ -0,0 +1,17 @@ +package local + +type OutputFormatType string + +const ( + OUTPUT_FORMAT_PEM = OutputFormatType("PEM") + OUTPUT_FORMAT_PFX = OutputFormatType("PFX") + OUTPUT_FORMAT_JKS = OutputFormatType("JKS") +) + +type ShellEnvType string + +const ( + SHELL_ENV_SH = ShellEnvType("sh") + SHELL_ENV_CMD = ShellEnvType("cmd") + SHELL_ENV_POWERSHELL = ShellEnvType("powershell") +) diff --git a/internal/pkg/core/deployer/providers/local/local.go b/internal/pkg/core/deployer/providers/local/local.go new file mode 100644 index 00000000..073014db --- /dev/null +++ b/internal/pkg/core/deployer/providers/local/local.go @@ -0,0 +1,177 @@ +package local + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "runtime" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/fs" + "github.com/usual2970/certimate/internal/pkg/utils/x509" +) + +type LocalDeployerConfig struct { + // Shell 执行环境。 + // 空值时默认根据操作系统决定。 + ShellEnv ShellEnvType `json:"shellEnv,omitempty"` + // 前置命令。 + PreCommand string `json:"preCommand,omitempty"` + // 后置命令。 + PostCommand string `json:"postCommand,omitempty"` + // 输出证书格式。 + // 空值时默认为 [OUTPUT_FORMAT_PEM]。 + OutputFormat OutputFormatType `json:"outputFormat,omitempty"` + // 输出证书文件路径。 + OutputCertPath string `json:"outputCertPath,omitempty"` + // 输出私钥文件路径。 + OutputKeyPath string `json:"outputKeyPath,omitempty"` + // PFX 导出密码。 + // 证书格式为 PFX 时必填。 + PfxPassword string `json:"pfxPassword,omitempty"` + // JKS 别名。 + // 证书格式为 JKS 时必填。 + JksAlias string `json:"jksAlias,omitempty"` + // JKS 密钥密码。 + // 证书格式为 JKS 时必填。 + JksKeypass string `json:"jksKeypass,omitempty"` + // JKS 存储密码。 + // 证书格式为 JKS 时必填。 + JksStorepass string `json:"jksStorepass,omitempty"` +} + +type LocalDeployer struct { + config *LocalDeployerConfig + logger deployer.Logger +} + +func New(config *LocalDeployerConfig) (*LocalDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *LocalDeployerConfig, logger deployer.Logger) (*LocalDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &LocalDeployer{ + logger: logger, + config: config, + }, nil +} + +func (d *LocalDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 执行前置命令 + if d.config.PreCommand != "" { + stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PreCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run pre-command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Appendt("pre-command executed", stdout) + } + + // 写入证书和私钥文件 + switch d.config.OutputFormat { + case "", OUTPUT_FORMAT_PEM: + if err := fs.WriteFileString(d.config.OutputCertPath, certPem); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file saved") + + if err := fs.WriteFileString(d.config.OutputKeyPath, privkeyPem); err != nil { + return nil, err + } + + d.logger.Appendt("private key file saved") + + case OUTPUT_FORMAT_PFX: + pfxData, err := x509.TransformCertificateFromPEMToPFX(certPem, privkeyPem, d.config.PfxPassword) + if err != nil { + return nil, err + } + + d.logger.Appendt("certificate transformed to PFX") + + if err := fs.WriteFile(d.config.OutputCertPath, pfxData); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file saved") + + case OUTPUT_FORMAT_JKS: + jksData, err := x509.TransformCertificateFromPEMToJKS(certPem, privkeyPem, d.config.JksAlias, d.config.JksKeypass, d.config.JksStorepass) + if err != nil { + return nil, err + } + + d.logger.Appendt("certificate transformed to JKS") + + if err := fs.WriteFile(d.config.OutputCertPath, jksData); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file uploaded") + + default: + return nil, fmt.Errorf("unsupported output format: %s", d.config.OutputFormat) + } + + // 执行后置命令 + if d.config.PostCommand != "" { + stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PostCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Appendt("post-command executed", stdout) + } + + return &deployer.DeployResult{}, nil +} + +func execCommand(shellEnv ShellEnvType, command string) (string, string, error) { + var cmd *exec.Cmd + + switch shellEnv { + case SHELL_ENV_SH: + cmd = exec.Command("sh", "-c", command) + + case SHELL_ENV_CMD: + cmd = exec.Command("cmd", "/C", command) + + case SHELL_ENV_POWERSHELL: + cmd = exec.Command("powershell", "-Command", 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 env: %s", shellEnv) + } + + var stdoutBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + err := cmd.Run() + if err != nil { + return "", "", xerrors.Wrap(err, "failed to execute shell command") + } + + return stdoutBuf.String(), stderrBuf.String(), nil +} diff --git a/internal/pkg/core/deployer/providers/ssh/define.go b/internal/pkg/core/deployer/providers/ssh/define.go new file mode 100644 index 00000000..6f30871b --- /dev/null +++ b/internal/pkg/core/deployer/providers/ssh/define.go @@ -0,0 +1,9 @@ +package ssh + +type OutputFormatType string + +const ( + OUTPUT_FORMAT_PEM = OutputFormatType("PEM") + OUTPUT_FORMAT_PFX = OutputFormatType("PFX") + OUTPUT_FORMAT_JKS = OutputFormatType("JKS") +) diff --git a/internal/pkg/core/deployer/providers/ssh/ssh.go b/internal/pkg/core/deployer/providers/ssh/ssh.go new file mode 100644 index 00000000..54ecfd58 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ssh/ssh.go @@ -0,0 +1,251 @@ +package ssh + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + + xerrors "github.com/pkg/errors" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/x509" +) + +type SshDeployerConfig struct { + // SSH 主机。 + // 空值时默认为 "localhost"。 + SshHost string `json:"sshHost,omitempty"` + // SSH 端口。 + // 空值时默认为 22。 + SshPort int32 `json:"sshPort,omitempty"` + // SSH 登录用户名。 + SshUsername string `json:"sshUsername,omitempty"` + // SSH 登录密码。 + SshPassword string `json:"sshPassword,omitempty"` + // SSH 登录私钥。 + SshKey string `json:"sshKey,omitempty"` + // SSH 登录私钥口令。 + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + // 前置命令。 + PreCommand string `json:"preCommand,omitempty"` + // 后置命令。 + PostCommand string `json:"postCommand,omitempty"` + // 输出证书格式。 + // 空值时默认为 [OUTPUT_FORMAT_PEM]。 + OutputFormat OutputFormatType `json:"outputFormat,omitempty"` + // 输出证书文件路径。 + OutputCertPath string `json:"outputCertPath,omitempty"` + // 输出私钥文件路径。 + OutputKeyPath string `json:"outputKeyPath,omitempty"` + // PFX 导出密码。 + // 证书格式为 PFX 时必填。 + PfxPassword string `json:"pfxPassword,omitempty"` + // JKS 别名。 + // 证书格式为 JKS 时必填。 + JksAlias string `json:"jksAlias,omitempty"` + // JKS 密钥密码。 + // 证书格式为 JKS 时必填。 + JksKeypass string `json:"jksKeypass,omitempty"` + // JKS 存储密码。 + // 证书格式为 JKS 时必填。 + JksStorepass string `json:"jksStorepass,omitempty"` +} + +type SshDeployer struct { + config *SshDeployerConfig + logger deployer.Logger +} + +func New(config *SshDeployerConfig) (*SshDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *SshDeployerConfig, logger deployer.Logger) (*SshDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &SshDeployer{ + logger: logger, + config: config, + }, nil +} + +func (d *SshDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + // 连接 + client, err := createSshClient( + d.config.SshHost, + d.config.SshPort, + d.config.SshUsername, + d.config.SshPassword, + d.config.SshKey, + d.config.SshKeyPassphrase, + ) + if err != nil { + return nil, err + } + defer client.Close() + + d.logger.Appendt("SSH connected") + + // 执行前置命令 + if d.config.PreCommand != "" { + stdout, stderr, err := execSshCommand(client, d.config.PreCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run pre-command: stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Appendt("SSH pre-command executed", stdout) + } + + // 上传证书和私钥文件 + switch d.config.OutputFormat { + case "", OUTPUT_FORMAT_PEM: + if err := writeSftpFileString(client, d.config.OutputCertPath, certPem); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file uploaded") + + if err := writeSftpFileString(client, d.config.OutputKeyPath, privkeyPem); err != nil { + return nil, err + } + + d.logger.Appendt("private key file uploaded") + + case OUTPUT_FORMAT_PFX: + pfxData, err := x509.TransformCertificateFromPEMToPFX(certPem, privkeyPem, d.config.PfxPassword) + if err != nil { + return nil, err + } + + d.logger.Appendt("certificate transformed to PFX") + + if err := writeSftpFile(client, d.config.OutputCertPath, pfxData); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file uploaded") + + case OUTPUT_FORMAT_JKS: + jksData, err := x509.TransformCertificateFromPEMToJKS(certPem, privkeyPem, d.config.JksAlias, d.config.JksKeypass, d.config.JksStorepass) + if err != nil { + return nil, err + } + + d.logger.Appendt("certificate transformed to JKS") + + if err := writeSftpFile(client, d.config.OutputCertPath, jksData); err != nil { + return nil, err + } + + d.logger.Appendt("certificate file uploaded") + + default: + return nil, fmt.Errorf("unsupported output format: %s", d.config.OutputFormat) + } + + // 执行后置命令 + if d.config.PostCommand != "" { + stdout, stderr, err := execSshCommand(client, d.config.PostCommand) + if err != nil { + return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr) + } + + d.logger.Appendt("SSH post-command executed", stdout) + } + + return &deployer.DeployResult{}, nil +} + +func createSshClient(host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) { + if host == "" { + host = "localhost" + } + + if port == 0 { + port = 22 + } + + var authMethod ssh.AuthMethod + if key != "" { + var signer ssh.Signer + var err error + + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + + if err != nil { + return nil, err + } + authMethod = ssh.PublicKeys(signer) + } else { + authMethod = ssh.Password(password) + } + + return ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{authMethod}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) +} + +func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) { + session, err := sshCli.NewSession() + if err != nil { + return "", "", xerrors.Wrap(err, "failed to create ssh session") + } + + defer session.Close() + var stdoutBuf bytes.Buffer + session.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf + err = session.Run(command) + if err != nil { + return "", "", xerrors.Wrap(err, "failed to execute ssh command") + } + + return stdoutBuf.String(), stderrBuf.String(), nil +} + +func writeSftpFileString(sshCli *ssh.Client, path string, content string) error { + return writeSftpFile(sshCli, path, []byte(content)) +} + +func writeSftpFile(sshCli *ssh.Client, path string, data []byte) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return xerrors.Wrap(err, "failed to create sftp client") + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil { + return xerrors.Wrap(err, "failed to create remote directory") + } + + file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return xerrors.Wrap(err, "failed to open remote file") + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return xerrors.Wrap(err, "failed to write to remote file") + } + + return nil +} diff --git a/internal/pkg/core/deployer/providers/webhook/webhook.go b/internal/pkg/core/deployer/providers/webhook/webhook.go new file mode 100644 index 00000000..4de107ff --- /dev/null +++ b/internal/pkg/core/deployer/providers/webhook/webhook.go @@ -0,0 +1,81 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/x509" + xhttp "github.com/usual2970/certimate/internal/utils/http" +) + +type WebhookDeployerConfig struct { + // Webhook URL。 + Url string `json:"url"` + // Webhook 变量字典。 + Variables map[string]string `json:"variables,omitempty"` +} + +type WebhookDeployer struct { + config *WebhookDeployerConfig + logger deployer.Logger +} + +var _ deployer.Deployer = (*WebhookDeployer)(nil) + +func New(config *WebhookDeployerConfig) (*WebhookDeployer, error) { + return NewWithLogger(config, deployer.NewNilLogger()) +} + +func NewWithLogger(config *WebhookDeployerConfig, logger deployer.Logger) (*WebhookDeployer, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + if logger == nil { + return nil, errors.New("logger is nil") + } + + return &WebhookDeployer{ + config: config, + logger: logger, + }, nil +} + +type webhookData struct { + SubjectAltNames string `json:"subjectAltNames"` + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + Variables map[string]string `json:"variables"` +} + +func (d *WebhookDeployer) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + certX509, err := x509.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + data := &webhookData{ + SubjectAltNames: strings.Join(certX509.DNSNames, ","), + Certificate: certPem, + PrivateKey: privkeyPem, + Variables: d.config.Variables, + } + body, _ := json.Marshal(data) + resp, err := xhttp.Req(d.config.Url, http.MethodPost, bytes.NewReader(body), map[string]string{ + "Content-Type": "application/json", + }) + if err != nil { + return nil, xerrors.Wrap(err, "failed to send webhook request") + } + + d.logger.Appendt("Webhook Response", string(resp)) + + return &deployer.DeployResult{}, nil +}