diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index d69b05b7..d4c61a31 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -57,6 +57,8 @@ import ( pQiniuCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/qiniu-cdn" pQiniuPili "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/qiniu-pili" pRainYunRCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/rainyun-rcdn" + pRatPanelConsole "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ratpanel-console" + pRatPanelSite "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ratpanel-site" pSafeLine "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/safeline" pSSH "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ssh" pTencentCloudCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-cdn" @@ -813,6 +815,38 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer } } + case domain.DeploymentProviderTypeRatPanelConsole, domain.DeploymentProviderTypeRatPanelSite: + { + access := domain.AccessConfigForRatPanel{} + if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + switch options.Provider { + case domain.DeploymentProviderTypeRatPanelConsole: + deployer, err := pRatPanelConsole.NewDeployer(&pRatPanelConsole.DeployerConfig{ + ApiUrl: access.ApiUrl, + AccessTokenId: access.AccessTokenId, + AccessToken: access.AccessToken, + AllowInsecureConnections: access.AllowInsecureConnections, + }) + return deployer, err + + case domain.DeploymentProviderTypeRatPanelSite: + deployer, err := pRatPanelSite.NewDeployer(&pRatPanelSite.DeployerConfig{ + ApiUrl: access.ApiUrl, + AccessTokenId: access.AccessTokenId, + AccessToken: access.AccessToken, + AllowInsecureConnections: access.AllowInsecureConnections, + SiteName: maputil.GetString(options.ProviderExtendedConfig, "siteName"), + }) + return deployer, err + + default: + break + } + } + case domain.DeploymentProviderTypeSafeLine: { access := domain.AccessConfigForSafeLine{} diff --git a/internal/domain/access.go b/internal/domain/access.go index e27d290a..8bc5d0ef 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -240,6 +240,13 @@ type AccessConfigForRainYun struct { ApiKey string `json:"apiKey"` } +type AccessConfigForRatPanel struct { + ApiUrl string `json:"apiUrl"` + AccessTokenId uint `json:"accessTokenId"` + AccessToken string `json:"accessToken"` + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` +} + type AccessConfigForSafeLine struct { ApiUrl string `json:"apiUrl"` ApiToken string `json:"apiToken"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 9226680d..4a0c05dd 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -64,6 +64,7 @@ const ( AccessProviderTypeQiniu = AccessProviderType("qiniu") AccessProviderTypeQingCloud = AccessProviderType("qingcloud") // 青云(预留) AccessProviderTypeRainYun = AccessProviderType("rainyun") + AccessProviderTypeRatPanel = AccessProviderType("ratpanel") AccessProviderTypeSafeLine = AccessProviderType("safeline") AccessProviderTypeSSH = AccessProviderType("ssh") AccessProviderTypeSSLCOM = AccessProviderType("sslcom") @@ -214,6 +215,8 @@ const ( DeploymentProviderTypeQiniuKodo = DeploymentProviderType(AccessProviderTypeQiniu + "-kodo") DeploymentProviderTypeQiniuPili = DeploymentProviderType(AccessProviderTypeQiniu + "-pili") DeploymentProviderTypeRainYunRCDN = DeploymentProviderType(AccessProviderTypeRainYun + "-rcdn") + DeploymentProviderTypeRatPanelConsole = DeploymentProviderType(AccessProviderTypeRatPanel + "-console") + DeploymentProviderTypeRatPanelSite = DeploymentProviderType(AccessProviderTypeRatPanel + "-site") DeploymentProviderTypeSafeLine = DeploymentProviderType(AccessProviderTypeSafeLine) DeploymentProviderTypeSSH = DeploymentProviderType(AccessProviderTypeSSH) DeploymentProviderTypeTencentCloudCDN = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cdn") diff --git a/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console.go b/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console.go new file mode 100644 index 00000000..ceb31771 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console.go @@ -0,0 +1,93 @@ +package ratpanelconsole + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net/url" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + rpsdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/ratpanel" +) + +type DeployerConfig struct { + // 耗子面板地址。 + ApiUrl string `json:"apiUrl"` + // 耗子面板访问令牌ID。 + AccessTokenId uint `json:"accessTokenId"` + // 耗子面板访问令牌。 + AccessToken string `json:"accessToken"` + // 是否允许不安全的连接。 + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` +} + +type DeployerProvider struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *rpsdk.Client +} + +var _ deployer.Deployer = (*DeployerProvider)(nil) + +func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.ApiUrl, config.AccessTokenId, config.AccessToken, config.AllowInsecureConnections) + if err != nil { + return nil, fmt.Errorf("failed to create sdk client: %w", err) + } + + return &DeployerProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer { + if logger == nil { + d.logger = slog.Default() + } else { + d.logger = logger + } + return d +} + +func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) { + // 设置面板 SSL 证书 + settingCertReq := &rpsdk.SettingCertRequest{ + Certificate: certPEM, + PrivateKey: privkeyPEM, + } + settingCertResp, err := d.sdkClient.SettingCert(settingCertReq) + d.logger.Debug("sdk request 'ratpanel.SettingCertRequest'", slog.Any("request", settingCertReq), slog.Any("response", settingCertResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'ratpanel.SettingCertRequest': %w", err) + } + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(apiUrl string, accessTokenId uint, accessToken string, skipTlsVerify bool) (*rpsdk.Client, error) { + if _, err := url.Parse(apiUrl); err != nil { + return nil, errors.New("invalid ratpanel api url") + } + + if accessTokenId == 0 { + return nil, errors.New("invalid ratpanel access token id") + } + if accessToken == "" { + return nil, errors.New("invalid ratpanel access token") + } + + client := rpsdk.NewClient(apiUrl, accessTokenId, accessToken) + if skipTlsVerify { + client.WithTLSConfig(&tls.Config{InsecureSkipVerify: true}) + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console_test.go b/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console_test.go new file mode 100644 index 00000000..40804f87 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ratpanel-console/ratpanel_console_test.go @@ -0,0 +1,76 @@ +package ratpanelconsole_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ratpanel-console" +) + +var ( + fInputCertPath string + fInputKeyPath string + fApiUrl string + fTokenId uint + fToken string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_RATPANELCONSOLE_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fApiUrl, argsPrefix+"APIURL", "", "") + flag.UintVar(&fTokenId, argsPrefix+"TOKENID", 0, "") + flag.StringVar(&fToken, argsPrefix+"TOKEN", "", "") +} + +/* +Shell command to run this test: + + go test -v ./ratpanel_console_test.go -args \ + --CERTIMATE_DEPLOYER_RATPANELCONSOLE_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_RATPANELCONSOLE_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_RATPANELCONSOLE_APIURL="http://127.0.0.1:8888" \ + --CERTIMATE_DEPLOYER_RATPANELCONSOLE_TOKENID=your-access-token-id \ + --CERTIMATE_DEPLOYER_RATPANELCONSOLE_TOKEN="your-access-token" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("APIURL: %v", fApiUrl), + fmt.Sprintf("TOKENID: %v", fTokenId), + fmt.Sprintf("TOKEN: %v", fToken), + }, "\n")) + + deployer, err := provider.NewDeployer(&provider.DeployerConfig{ + ApiUrl: fApiUrl, + AccessTokenId: fTokenId, + AccessToken: fToken, + AllowInsecureConnections: true, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site.go b/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site.go new file mode 100644 index 00000000..85f54ab5 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site.go @@ -0,0 +1,100 @@ +package ratpanelsite + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net/url" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + rpsdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/ratpanel" +) + +type DeployerConfig struct { + // 耗子面板地址。 + ApiUrl string `json:"apiUrl"` + // 耗子面板访问令牌ID。 + AccessTokenId uint `json:"accessTokenId"` + // 耗子面板访问令牌。 + AccessToken string `json:"accessToken"` + // 是否允许不安全的连接。 + AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` + // 网站名称。 + SiteName string `json:"siteName,omitempty"` +} + +type DeployerProvider struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *rpsdk.Client +} + +var _ deployer.Deployer = (*DeployerProvider)(nil) + +func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.ApiUrl, config.AccessTokenId, config.AccessToken, config.AllowInsecureConnections) + if err != nil { + return nil, fmt.Errorf("failed to create sdk client: %w", err) + } + + return &DeployerProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer { + if logger == nil { + d.logger = slog.Default() + } else { + d.logger = logger + } + return d +} + +func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) { + if d.config.SiteName == "" { + return nil, errors.New("config `siteName` is required") + } + + // 设置站点 SSL 证书 + websiteCertReq := &rpsdk.WebsiteCertRequest{ + SiteName: d.config.SiteName, + Certificate: certPEM, + PrivateKey: privkeyPEM, + } + websiteCertResp, err := d.sdkClient.WebsiteCert(websiteCertReq) + d.logger.Debug("sdk request 'ratpanel.WebsiteCertRequest'", slog.Any("request", websiteCertReq), slog.Any("response", websiteCertResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'ratpanel.WebsiteCertRequest': %w", err) + } + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(apiUrl string, accessTokenId uint, accessToken string, skipTlsVerify bool) (*rpsdk.Client, error) { + if _, err := url.Parse(apiUrl); err != nil { + return nil, errors.New("invalid ratpanel api url") + } + + if accessTokenId == 0 { + return nil, errors.New("invalid ratpanel access token id") + } + if accessToken == "" { + return nil, errors.New("invalid ratpanel access token") + } + + client := rpsdk.NewClient(apiUrl, accessTokenId, accessToken) + if skipTlsVerify { + client.WithTLSConfig(&tls.Config{InsecureSkipVerify: true}) + } + + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site_test.go b/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site_test.go new file mode 100644 index 00000000..a4cab040 --- /dev/null +++ b/internal/pkg/core/deployer/providers/ratpanel-site/ratpanel_site_test.go @@ -0,0 +1,81 @@ +package ratpanelsite_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ratpanel-site" +) + +var ( + fInputCertPath string + fInputKeyPath string + fApiUrl string + fTokenId uint + fToken string + fSiteName string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_RATPANELSITE_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fApiUrl, argsPrefix+"APIURL", "", "") + flag.UintVar(&fTokenId, argsPrefix+"TOKENID", 0, "") + flag.StringVar(&fToken, argsPrefix+"TOKEN", "", "") + flag.StringVar(&fSiteName, argsPrefix+"SITENAME", "", "") +} + +/* +Shell command to run this test: + + go test -v ./ratpanel_site_test.go -args \ + --CERTIMATE_DEPLOYER_RATPANELSITE_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_RATPANELSITE_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_RATPANELSITE_APIURL="http://127.0.0.1:8888" \ + --CERTIMATE_DEPLOYER_RATPANELSITE_TOKENID=your-access-token-id \ + --CERTIMATE_DEPLOYER_RATPANELSITE_TOKEN="your-access-token" \ + --CERTIMATE_DEPLOYER_RATPANELSITE_SITENAME="your-site-name" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("APIURL: %v", fApiUrl), + fmt.Sprintf("TOKENID: %v", fTokenId), + fmt.Sprintf("TOKEN: %v", fToken), + fmt.Sprintf("SITENAME: %v", fSiteName), + }, "\n")) + + deployer, err := provider.NewDeployer(&provider.DeployerConfig{ + ApiUrl: fApiUrl, + AccessTokenId: fTokenId, + AccessToken: fToken, + AllowInsecureConnections: true, + SiteName: fSiteName, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/sdk3rd/ratpanel/api.go b/internal/pkg/sdk3rd/ratpanel/api.go new file mode 100644 index 00000000..17f8110f --- /dev/null +++ b/internal/pkg/sdk3rd/ratpanel/api.go @@ -0,0 +1,15 @@ +package ratpanelsdk + +import "net/http" + +func (c *Client) SettingCert(req *SettingCertRequest) (*SettingCertResponse, error) { + resp := &SettingCertResponse{} + err := c.sendRequestWithResult(http.MethodPost, "/setting/cert", req, resp) + return resp, err +} + +func (c *Client) WebsiteCert(req *WebsiteCertRequest) (*WebsiteCertResponse, error) { + resp := &WebsiteCertResponse{} + err := c.sendRequestWithResult(http.MethodPost, "/website/cert", req, resp) + return resp, err +} diff --git a/internal/pkg/sdk3rd/ratpanel/client.go b/internal/pkg/sdk3rd/ratpanel/client.go new file mode 100644 index 00000000..e0562410 --- /dev/null +++ b/internal/pkg/sdk3rd/ratpanel/client.go @@ -0,0 +1,145 @@ +package ratpanelsdk + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +type Client struct { + client *resty.Client +} + +func NewClient(apiHost string, accessTokenId uint, accessToken string) *Client { + client := resty.New(). + SetBaseURL(strings.TrimRight(apiHost, "/")+"/api"). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetPreRequestHook(func(c *resty.Client, req *http.Request) error { + var body []byte + var err error + + if req.Body != nil { + body, err = io.ReadAll(req.Body) + if err != nil { + return err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + } + + canonicalPath := req.URL.Path + if !strings.HasPrefix(canonicalPath, "/api") { + index := strings.Index(canonicalPath, "/api") + if index != -1 { + canonicalPath = canonicalPath[index:] + } + } + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s", + req.Method, + canonicalPath, + req.URL.Query().Encode(), + sha256Sum(string(body))) + + timestamp := time.Now().Unix() + req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp)) + + stringToSign := fmt.Sprintf("%s\n%d\n%s", + "HMAC-SHA256", + timestamp, + sha256Sum(canonicalRequest)) + signature := hmacSha256(stringToSign, accessToken) + req.Header.Set("Authorization", fmt.Sprintf("HMAC-SHA256 Credential=%d, Signature=%s", accessTokenId, signature)) + + return nil + }) + + return &Client{ + client: client, + } +} + +func (c *Client) WithTimeout(timeout time.Duration) *Client { + c.client.SetTimeout(timeout) + return c +} + +func (c *Client) WithTLSConfig(config *tls.Config) *Client { + c.client.SetTLSClientConfig(config) + return c +} + +func (c *Client) sendRequest(method string, path string, params interface{}) (*resty.Response, error) { + req := c.client.R() + req.Method = method + req.URL = path + if strings.EqualFold(method, http.MethodGet) { + qs := make(map[string]string) + if params != nil { + temp := make(map[string]any) + jsonb, _ := json.Marshal(params) + json.Unmarshal(jsonb, &temp) + for k, v := range temp { + if v != nil { + qs[k] = fmt.Sprintf("%v", v) + } + } + } + + req = req.SetQueryParams(qs) + } else { + req = req. + SetHeader("Content-Type", "application/json"). + SetBody(params) + } + + resp, err := req.Send() + if err != nil { + return resp, fmt.Errorf("ratpanel api error: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("ratpanel api error: unexpected status code: %d, resp: %s", resp.StatusCode(), resp.Body()) + } + + return resp, nil +} + +func (c *Client) sendRequestWithResult(method string, path string, params interface{}, result BaseResponse) error { + resp, err := c.sendRequest(method, path, params) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &result) + } + return err + } + + if err = json.Unmarshal(resp.Body(), &result); err != nil { + return fmt.Errorf("ratpanel api error: failed to parse response: %w", err) + } else if errmessage := result.GetMessage(); errmessage != "success" { + return fmt.Errorf("ratpanel api error: %d - %s", resp.StatusCode(), errmessage) + } + + return nil +} + +func sha256Sum(str string) string { + sum := sha256.Sum256([]byte(str)) + dst := make([]byte, hex.EncodedLen(len(sum))) + hex.Encode(dst, sum[:]) + return string(dst) +} + +func hmacSha256(data string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/pkg/sdk3rd/ratpanel/models.go b/internal/pkg/sdk3rd/ratpanel/models.go new file mode 100644 index 00000000..bf5f53fb --- /dev/null +++ b/internal/pkg/sdk3rd/ratpanel/models.go @@ -0,0 +1,35 @@ +package ratpanelsdk + +type BaseResponse interface { + GetMessage() string +} + +type baseResponse struct { + Message *string `json:"msg,omitempty"` +} + +func (r *baseResponse) GetMessage() string { + if r.Message != nil { + return *r.Message + } + return "" +} + +type SettingCertRequest struct { + Certificate string `json:"cert"` + PrivateKey string `json:"key"` +} + +type SettingCertResponse struct { + baseResponse +} + +type WebsiteCertRequest struct { + SiteName string `json:"name"` + Certificate string `json:"cert"` + PrivateKey string `json:"key"` +} + +type WebsiteCertResponse struct { + baseResponse +}