feat: support more content-type in webhook

This commit is contained in:
Fu Diwei 2025-04-27 00:01:00 +08:00
parent 609a252ee0
commit 193a19b79c
4 changed files with 180 additions and 35 deletions

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -68,14 +69,58 @@ func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer {
} }
func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) { func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) {
// 解析证书内容
certX509, err := certutil.ParseCertificateFromPEM(certPEM) certX509, err := certutil.ParseCertificateFromPEM(certPEM)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse x509: %w", err) return nil, fmt.Errorf("failed to parse x509: %w", err)
} }
// 处理 Webhook URL
webhookUrl, err := url.Parse(d.config.WebhookUrl)
if err != nil {
return nil, fmt.Errorf("failed to parse webhook url: %w", err)
} else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" {
return nil, fmt.Errorf("unsupported webhook url scheme: %s", webhookUrl.Scheme)
} else {
webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName))
}
// 处理 Webhook 请求谓词
webhookMethod := strings.ToUpper(d.config.Method)
if webhookMethod == "" {
webhookMethod = http.MethodPost
} else if webhookMethod != http.MethodGet &&
webhookMethod != http.MethodPost &&
webhookMethod != http.MethodPut &&
webhookMethod != http.MethodPatch &&
webhookMethod != http.MethodDelete {
return nil, fmt.Errorf("unsupported webhook request method: %s", webhookMethod)
}
// 处理 Webhook 请求标头
webhookHeaders := make(http.Header)
for k, v := range d.config.Headers {
webhookHeaders.Set(k, v)
}
// 处理 Webhook 请求内容类型
const CONTENT_TYPE_JSON = "application/json"
const CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"
const CONTENT_TYPE_MULTIPART = "multipart/form-data"
webhookContentType := webhookHeaders.Get("Content-Type")
if webhookContentType == "" {
webhookContentType = CONTENT_TYPE_JSON
webhookHeaders.Set("Content-Type", CONTENT_TYPE_JSON)
} else if strings.HasPrefix(webhookContentType, CONTENT_TYPE_JSON) &&
strings.HasPrefix(webhookContentType, CONTENT_TYPE_FORM) &&
strings.HasPrefix(webhookContentType, CONTENT_TYPE_MULTIPART) {
return nil, fmt.Errorf("unsupported webhook content type: %s", webhookContentType)
}
// 处理 Webhook 请求数据
var webhookData interface{} var webhookData interface{}
if d.config.WebhookData == "" { if d.config.WebhookData == "" {
webhookData = map[string]any{ webhookData = map[string]string{
"name": strings.Join(certX509.DNSNames, ";"), "name": strings.Join(certX509.DNSNames, ";"),
"cert": certPEM, "cert": certPEM,
"privkey": privkeyPEM, "privkey": privkeyPEM,
@ -83,29 +128,49 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
} else { } else {
err = json.Unmarshal([]byte(d.config.WebhookData), &webhookData) err = json.Unmarshal([]byte(d.config.WebhookData), &webhookData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to unmarshall webhook data: %w", err) return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} }
replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName) replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName)
replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";"))
replaceJsonValueRecursively(webhookData, "${SUBJECT_ALT_NAMES}", strings.Join(certX509.DNSNames, ";"))
replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM) replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM)
replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM) replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM)
if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART {
temp := make(map[string]string)
jsonb, err := json.Marshal(webhookData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} else if err := json.Unmarshal(jsonb, &temp); err != nil {
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} else {
webhookData = temp
}
}
} }
// 生成请求
// 其中 GET 请求需转换为查询参数
req := d.httpClient.R(). req := d.httpClient.R().
SetContext(ctx). SetContext(ctx).
SetHeaders(d.config.Headers) SetHeaderMultiValues(webhookHeaders)
req.URL = d.config.WebhookUrl req.URL = webhookUrl.String()
req.Method = d.config.Method req.Method = webhookMethod
if req.Method == "" { if webhookMethod == http.MethodGet {
req.Method = http.MethodPost req.SetQueryParams(webhookData.(map[string]string))
} else {
switch webhookContentType {
case CONTENT_TYPE_JSON:
req.SetBody(webhookData)
case CONTENT_TYPE_FORM:
req.SetFormData(webhookData.(map[string]string))
case CONTENT_TYPE_MULTIPART:
req.SetMultipartFormData(webhookData.(map[string]string))
}
} }
resp, err := req. // 发送请求
SetHeader("Content-Type", "application/json"). resp, err := req.SetDebug(true).Send()
SetBody(webhookData).
Send()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send webhook request: %w", err) return nil, fmt.Errorf("failed to send webhook request: %w", err)
} else if resp.IsError() { } else if resp.IsError() {

View File

@ -12,10 +12,11 @@ import (
) )
var ( var (
fInputCertPath string fInputCertPath string
fInputKeyPath string fInputKeyPath string
fWebhookUrl string fWebhookUrl string
fWebhookData string fWebhookContentType string
fWebhookData string
) )
func init() { func init() {
@ -24,6 +25,7 @@ func init() {
flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "")
flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "")
flag.StringVar(&fWebhookUrl, argsPrefix+"URL", "", "") flag.StringVar(&fWebhookUrl, argsPrefix+"URL", "", "")
flag.StringVar(&fWebhookContentType, argsPrefix+"CONTENTTYPE", "application/json", "")
flag.StringVar(&fWebhookData, argsPrefix+"DATA", "", "") flag.StringVar(&fWebhookData, argsPrefix+"DATA", "", "")
} }
@ -34,7 +36,8 @@ Shell command to run this test:
--CERTIMATE_DEPLOYER_WEBHOOK_INPUTCERTPATH="/path/to/your-input-cert.pem" \ --CERTIMATE_DEPLOYER_WEBHOOK_INPUTCERTPATH="/path/to/your-input-cert.pem" \
--CERTIMATE_DEPLOYER_WEBHOOK_INPUTKEYPATH="/path/to/your-input-key.pem" \ --CERTIMATE_DEPLOYER_WEBHOOK_INPUTKEYPATH="/path/to/your-input-key.pem" \
--CERTIMATE_DEPLOYER_WEBHOOK_URL="https://example.com/your-webhook-url" \ --CERTIMATE_DEPLOYER_WEBHOOK_URL="https://example.com/your-webhook-url" \
--CERTIMATE_DEPLOYER_WEBHOOK_DATA="{\"certificate\":\"${Certificate}\",\"privateKey\":\"${PrivateKey}\"}" --CERTIMATE_DEPLOYER_WEBHOOK_CONTENTTYPE="application/json" \
--CERTIMATE_DEPLOYER_WEBHOOK_DATA="{\"certificate\":\"${CERTIFICATE}\",\"privateKey\":\"${PRIVATE_KEY}\"}"
*/ */
func TestDeploy(t *testing.T) { func TestDeploy(t *testing.T) {
flag.Parse() flag.Parse()
@ -45,12 +48,17 @@ func TestDeploy(t *testing.T) {
fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
fmt.Sprintf("WEBHOOKURL: %v", fWebhookUrl), fmt.Sprintf("WEBHOOKURL: %v", fWebhookUrl),
fmt.Sprintf("WEBHOOKCONTENTTYPE: %v", fWebhookContentType),
fmt.Sprintf("WEBHOOKDATA: %v", fWebhookData), fmt.Sprintf("WEBHOOKDATA: %v", fWebhookData),
}, "\n")) }, "\n"))
deployer, err := provider.NewDeployer(&provider.DeployerConfig{ deployer, err := provider.NewDeployer(&provider.DeployerConfig{
WebhookUrl: fWebhookUrl, WebhookUrl: fWebhookUrl,
WebhookData: fWebhookData, WebhookData: fWebhookData,
Method: "POST",
Headers: map[string]string{
"Content-Type": fWebhookContentType,
},
AllowInsecureConnections: true, AllowInsecureConnections: true,
}) })
if err != nil { if err != nil {

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -67,35 +68,97 @@ func (n *NotifierProvider) WithLogger(logger *slog.Logger) notifier.Notifier {
} }
func (n *NotifierProvider) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { func (n *NotifierProvider) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) {
// 处理 Webhook URL
webhookUrl, err := url.Parse(n.config.WebhookUrl)
if err != nil {
return nil, fmt.Errorf("failed to parse webhook url: %w", err)
} else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" {
return nil, fmt.Errorf("unsupported webhook url scheme: %s", webhookUrl.Scheme)
}
// 处理 Webhook 请求谓词
webhookMethod := strings.ToUpper(n.config.Method)
if webhookMethod == "" {
webhookMethod = http.MethodPost
} else if webhookMethod != http.MethodGet &&
webhookMethod != http.MethodPost &&
webhookMethod != http.MethodPut &&
webhookMethod != http.MethodPatch &&
webhookMethod != http.MethodDelete {
return nil, fmt.Errorf("unsupported webhook request method: %s", webhookMethod)
}
// 处理 Webhook 请求标头
webhookHeaders := make(http.Header)
for k, v := range n.config.Headers {
webhookHeaders.Set(k, v)
}
// 处理 Webhook 请求内容类型
const CONTENT_TYPE_JSON = "application/json"
const CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"
const CONTENT_TYPE_MULTIPART = "multipart/form-data"
webhookContentType := webhookHeaders.Get("Content-Type")
if webhookContentType == "" {
webhookContentType = CONTENT_TYPE_JSON
webhookHeaders.Set("Content-Type", CONTENT_TYPE_JSON)
} else if strings.HasPrefix(webhookContentType, CONTENT_TYPE_JSON) &&
strings.HasPrefix(webhookContentType, CONTENT_TYPE_FORM) &&
strings.HasPrefix(webhookContentType, CONTENT_TYPE_MULTIPART) {
return nil, fmt.Errorf("unsupported webhook content type: %s", webhookContentType)
}
// 处理 Webhook 请求数据
var webhookData interface{} var webhookData interface{}
if n.config.WebhookData == "" { if n.config.WebhookData == "" {
webhookData = map[string]any{ webhookData = map[string]string{
"subject": subject, "subject": subject,
"message": message, "message": message,
} }
} else { } else {
err = json.Unmarshal([]byte(n.config.WebhookData), &webhookData) err = json.Unmarshal([]byte(n.config.WebhookData), &webhookData)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to unmarshall webhook data: %w", err) return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} }
replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject) replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject)
replaceJsonValueRecursively(webhookData, "${MESSAGE}", message) replaceJsonValueRecursively(webhookData, "${MESSAGE}", message)
if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART {
temp := make(map[string]string)
jsonb, err := json.Marshal(webhookData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} else if err := json.Unmarshal(jsonb, &temp); err != nil {
return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err)
} else {
webhookData = temp
}
}
} }
// 生成请求
// 其中 GET 请求需转换为查询参数
req := n.httpClient.R(). req := n.httpClient.R().
SetContext(ctx). SetContext(ctx).
SetHeaders(n.config.Headers) SetHeaderMultiValues(webhookHeaders)
req.URL = n.config.WebhookUrl req.URL = webhookUrl.String()
req.Method = n.config.Method req.Method = webhookMethod
if req.Method == "" { if webhookMethod == http.MethodGet {
req.Method = http.MethodPost req.SetQueryParams(webhookData.(map[string]string))
} else {
switch webhookContentType {
case CONTENT_TYPE_JSON:
req.SetBody(webhookData)
case CONTENT_TYPE_FORM:
req.SetFormData(webhookData.(map[string]string))
case CONTENT_TYPE_MULTIPART:
req.SetMultipartFormData(webhookData.(map[string]string))
}
} }
resp, err := req. // 发送请求
SetHeader("Content-Type", "application/json"). resp, err := req.Send()
SetBody(webhookData).
Send()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send webhook request: %w", err) return nil, fmt.Errorf("failed to send webhook request: %w", err)
} else if resp.IsError() { } else if resp.IsError() {

View File

@ -15,19 +15,24 @@ const (
mockMessage = "test_message" mockMessage = "test_message"
) )
var fUrl string var (
fWebhookUrl string
fWebhookContentType string
)
func init() { func init() {
argsPrefix := "CERTIMATE_NOTIFIER_WEBHOOK_" argsPrefix := "CERTIMATE_NOTIFIER_WEBHOOK_"
flag.StringVar(&fUrl, argsPrefix+"URL", "", "") flag.StringVar(&fWebhookUrl, argsPrefix+"URL", "", "")
flag.StringVar(&fWebhookContentType, argsPrefix+"CONTENTTYPE", "application/json", "")
} }
/* /*
Shell command to run this test: Shell command to run this test:
go test -v ./webhook_test.go -args \ go test -v ./webhook_test.go -args \
--CERTIMATE_NOTIFIER_WEBHOOK_URL="https://example.com/your-webhook-url" --CERTIMATE_NOTIFIER_WEBHOOK_URL="https://example.com/your-webhook-url" \
--CERTIMATE_NOTIFIER_WEBHOOK_CONTENTTYPE="application/json"
*/ */
func TestNotify(t *testing.T) { func TestNotify(t *testing.T) {
flag.Parse() flag.Parse()
@ -35,11 +40,15 @@ func TestNotify(t *testing.T) {
t.Run("Notify", func(t *testing.T) { t.Run("Notify", func(t *testing.T) {
t.Log(strings.Join([]string{ t.Log(strings.Join([]string{
"args:", "args:",
fmt.Sprintf("URL: %v", fUrl), fmt.Sprintf("URL: %v", fWebhookUrl),
}, "\n")) }, "\n"))
notifier, err := provider.NewNotifier(&provider.NotifierConfig{ notifier, err := provider.NewNotifier(&provider.NotifierConfig{
WebhookUrl: fUrl, WebhookUrl: fWebhookUrl,
Method: "POST",
Headers: map[string]string{
"Content-Type": fWebhookContentType,
},
AllowInsecureConnections: true, AllowInsecureConnections: true,
}) })
if err != nil { if err != nil {