535 lines
16 KiB
Go
535 lines
16 KiB
Go
package notification
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"net/http"
|
||
"net/mail"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||
)
|
||
|
||
// CredentialReader 读取加密凭据存储中的通知认证信息。
|
||
type CredentialReader interface {
|
||
Get(context.Context, string) (map[string]string, error)
|
||
}
|
||
|
||
// HTTPDoer 抽象 HTTP 客户端,便于测试重试、认证和响应处理。
|
||
type HTTPDoer interface {
|
||
Do(*http.Request) (*http.Response, error)
|
||
}
|
||
|
||
// Notifier 发送证书任务通知。
|
||
type Notifier interface {
|
||
Send(context.Context, Event) error
|
||
}
|
||
|
||
// Event 描述一次证书申请、续期、导入或部署任务。
|
||
type Event struct {
|
||
Type string
|
||
Status string
|
||
DomainID string
|
||
Domain string
|
||
Domains []string
|
||
ExpiresAt *time.Time
|
||
CertificateSerial string
|
||
Deployment deployment.Report
|
||
ManagementURL string
|
||
Error string
|
||
}
|
||
|
||
type address struct {
|
||
Name string `json:"name,omitempty"`
|
||
Email string `json:"email"`
|
||
}
|
||
|
||
type mailPayload struct {
|
||
From address `json:"from"`
|
||
To []address `json:"to"`
|
||
Subject string `json:"subject"`
|
||
Text string `json:"text"`
|
||
HTML string `json:"html"`
|
||
CC []address `json:"cc"`
|
||
BCC []address `json:"bcc"`
|
||
Attrs []any `json:"attrs"`
|
||
}
|
||
|
||
type sessionAuth struct {
|
||
cookie string
|
||
csrf string
|
||
origin string
|
||
}
|
||
|
||
type sessionExpiredError struct {
|
||
statusCode int
|
||
errorCode int
|
||
}
|
||
|
||
func (e *sessionExpiredError) Error() string {
|
||
if e.statusCode != 0 {
|
||
return fmt.Sprintf("邮件服务登录会话已失效,HTTP 状态码 %d", e.statusCode)
|
||
}
|
||
return fmt.Sprintf("邮件服务登录会话已失效,业务错误码 %d", e.errorCode)
|
||
}
|
||
|
||
// Service 通过 HTTP 邮件 API 发送通知。
|
||
type Service struct {
|
||
config config.NotificationConfig
|
||
credentials CredentialReader
|
||
client HTTPDoer
|
||
now func() time.Time
|
||
requestID func() string
|
||
sleep func(context.Context, time.Duration) error
|
||
}
|
||
|
||
// New 创建 HTTP 通知服务。SMTP 配置暂保留为兼容字段,当前发送器只执行 HTTP 通知。
|
||
func New(cfg config.NotificationConfig, credentials CredentialReader) *Service {
|
||
return &Service{
|
||
config: cfg,
|
||
credentials: credentials,
|
||
client: &http.Client{},
|
||
now: time.Now,
|
||
requestID: newRequestID,
|
||
sleep: sleepContext,
|
||
}
|
||
}
|
||
|
||
// Send 按配置发送通知;未启用任何通知通道时直接返回成功。
|
||
func (s *Service) Send(ctx context.Context, event Event) error {
|
||
if s == nil {
|
||
return errors.New("通知服务未初始化")
|
||
}
|
||
if !s.config.HTTPEnabled && !s.config.SMTPEnabled {
|
||
return nil
|
||
}
|
||
if !s.config.HTTPEnabled {
|
||
return errors.New("当前仅支持 HTTP 邮件通知,SMTP 通知发送器尚未接入")
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
payload, err := s.buildPayload(event)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("编码邮件通知失败:%w", err)
|
||
}
|
||
endpoint, err := notificationURL(s.config.Endpoint, s.config.HTTPPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
credentials, err := s.loadCredentials(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var session sessionAuth
|
||
if s.config.HTTPAuthMode == "session" {
|
||
session, err = s.loginSession(ctx, credentials)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
requestID := s.requestID()
|
||
if requestID == "" {
|
||
return errors.New("生成通知请求 ID 失败")
|
||
}
|
||
var lastErr error
|
||
refreshSession := false
|
||
for attempt := 0; attempt <= s.config.RetryCount; attempt++ {
|
||
if attempt > 0 {
|
||
interval := time.Duration(0)
|
||
if attempt-1 < len(s.config.RetryIntervalsSecs) {
|
||
interval = time.Duration(s.config.RetryIntervalsSecs[attempt-1]) * time.Second
|
||
}
|
||
if err := s.sleep(ctx, interval); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if attempt > 0 && s.config.HTTPAuthMode == "session" && refreshSession {
|
||
session, err = s.loginSession(ctx, credentials)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
refreshSession = false
|
||
}
|
||
err := s.sendOnce(ctx, endpoint, body, credentials, requestID, session)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
lastErr = err
|
||
var expired *sessionExpiredError
|
||
refreshSession = errors.As(err, &expired)
|
||
}
|
||
return fmt.Errorf("发送邮件通知失败,已重试 %d 次:%w", s.config.RetryCount, lastErr)
|
||
}
|
||
|
||
func (s *Service) sendOnce(ctx context.Context, endpoint string, body []byte, credentials map[string]string, requestID string, session sessionAuth) error {
|
||
timeout := time.Duration(s.config.TimeoutSecs) * time.Second
|
||
if timeout < time.Second {
|
||
timeout = time.Second
|
||
}
|
||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return fmt.Errorf("创建邮件通知请求失败:%w", err)
|
||
}
|
||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||
request.Header.Set("Accept", "application/json")
|
||
request.Header.Set("X-Scert-Request-Id", requestID)
|
||
timestamp := strconv.FormatInt(s.now().UTC().Unix(), 10)
|
||
request.Header.Set("X-Scert-Timestamp", timestamp)
|
||
if s.config.HTTPAuthMode == "session" {
|
||
if session.cookie == "" {
|
||
return errors.New("HTTP 通知登录会话为空")
|
||
}
|
||
request.Header.Set("Cookie", session.cookie)
|
||
if session.csrf != "" {
|
||
request.Header.Set("X-CSRF-Token", session.csrf)
|
||
}
|
||
request.Header.Set("Origin", session.origin)
|
||
request.Header.Set("Referer", session.origin+"/")
|
||
}
|
||
for key, value := range s.config.HTTPHeaders {
|
||
if strings.TrimSpace(key) == "" || strings.ContainsAny(value, "\r\n") {
|
||
return fmt.Errorf("HTTP 自定义请求头无效:%s", key)
|
||
}
|
||
if isProtectedHeader(key) {
|
||
return fmt.Errorf("HTTP 自定义请求头不允许覆盖安全头:%s", key)
|
||
}
|
||
request.Header.Set(key, value)
|
||
}
|
||
switch s.config.HTTPAuthMode {
|
||
case "none", "":
|
||
case "bearer":
|
||
token := credentialValue(credentials, "token", "access_token")
|
||
if token == "" {
|
||
return errors.New("HTTP Bearer 凭据中缺少 Token")
|
||
}
|
||
request.Header.Set("Authorization", "Bearer "+token)
|
||
case "hmac_sha256":
|
||
secret := credentialValue(credentials, "secret", "hmac_secret", "key", "token")
|
||
if secret == "" {
|
||
return errors.New("HTTP HMAC 凭据中缺少签名密钥")
|
||
}
|
||
signature := sign(secret, timestamp, requestID, body)
|
||
request.Header.Set("X-Scert-Signature", "sha256="+signature)
|
||
case "session":
|
||
default:
|
||
return fmt.Errorf("HTTP 通知认证方式无效:%s", s.config.HTTPAuthMode)
|
||
}
|
||
client := s.client
|
||
if client == nil {
|
||
client = &http.Client{}
|
||
}
|
||
response, err := client.Do(request)
|
||
if err != nil {
|
||
return fmt.Errorf("请求邮件服务失败:%w", err)
|
||
}
|
||
defer response.Body.Close()
|
||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
|
||
if err != nil {
|
||
return fmt.Errorf("读取邮件服务响应失败:%w", err)
|
||
}
|
||
if len(responseBody) > 64<<10 {
|
||
return errors.New("邮件服务响应超过允许大小")
|
||
}
|
||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||
return &sessionExpiredError{statusCode: response.StatusCode}
|
||
}
|
||
return fmt.Errorf("邮件服务返回 HTTP %d", response.StatusCode)
|
||
}
|
||
var envelope struct {
|
||
ErrorNo *int `json:"errorNo"`
|
||
}
|
||
if len(responseBody) > 0 && json.Unmarshal(responseBody, &envelope) == nil && envelope.ErrorNo != nil && *envelope.ErrorNo != 0 {
|
||
if *envelope.ErrorNo == http.StatusUnauthorized || *envelope.ErrorNo == http.StatusForbidden {
|
||
return &sessionExpiredError{errorCode: *envelope.ErrorNo}
|
||
}
|
||
return fmt.Errorf("邮件服务业务失败,错误码 %d", *envelope.ErrorNo)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) loginSession(ctx context.Context, credentials map[string]string) (sessionAuth, error) {
|
||
account := credentialValue(credentials, "account", "username", "identifier")
|
||
password := credentials["password"]
|
||
if account == "" || strings.TrimSpace(password) == "" {
|
||
return sessionAuth{}, errors.New("HTTP 通知会话凭据必须包含账号和密码")
|
||
}
|
||
loginURL, err := notificationURL(s.config.Endpoint, s.config.HTTPLoginPath)
|
||
if err != nil {
|
||
return sessionAuth{}, err
|
||
}
|
||
payload, err := json.Marshal(map[string]string{"account": account, "password": password})
|
||
if err != nil {
|
||
return sessionAuth{}, fmt.Errorf("编码 HTTP 通知登录请求失败:%w", err)
|
||
}
|
||
timeout := time.Duration(s.config.TimeoutSecs) * time.Second
|
||
if timeout < time.Second {
|
||
timeout = time.Second
|
||
}
|
||
loginCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
request, err := http.NewRequestWithContext(loginCtx, http.MethodPost, loginURL, bytes.NewReader(payload))
|
||
if err != nil {
|
||
return sessionAuth{}, fmt.Errorf("创建 HTTP 通知登录请求失败:%w", err)
|
||
}
|
||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||
request.Header.Set("Accept", "application/json")
|
||
request.Header.Set("Origin", notificationOrigin(loginURL))
|
||
request.Header.Set("Referer", notificationOrigin(loginURL)+"/")
|
||
client := s.client
|
||
if client == nil {
|
||
client = &http.Client{}
|
||
}
|
||
response, err := client.Do(request)
|
||
if err != nil {
|
||
return sessionAuth{}, fmt.Errorf("请求 HTTP 通知登录接口失败:%w", err)
|
||
}
|
||
defer response.Body.Close()
|
||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||
if err != nil {
|
||
return sessionAuth{}, fmt.Errorf("读取 HTTP 通知登录响应失败:%w", err)
|
||
}
|
||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||
return sessionAuth{}, fmt.Errorf("HTTP 通知登录接口返回 HTTP %d", response.StatusCode)
|
||
}
|
||
var envelope struct {
|
||
ErrorNo *int `json:"errorNo"`
|
||
ErrorMsg string `json:"errorMsg"`
|
||
}
|
||
if len(responseBody) > 0 && json.Unmarshal(responseBody, &envelope) == nil && envelope.ErrorNo != nil && *envelope.ErrorNo != 0 {
|
||
if strings.TrimSpace(envelope.ErrorMsg) == "" {
|
||
return sessionAuth{}, fmt.Errorf("HTTP 通知登录失败,错误码 %d", *envelope.ErrorNo)
|
||
}
|
||
return sessionAuth{}, fmt.Errorf("HTTP 通知登录失败,错误码 %d", *envelope.ErrorNo)
|
||
}
|
||
cookies := make([]string, 0, len(response.Cookies()))
|
||
csrf := ""
|
||
for _, cookie := range response.Cookies() {
|
||
cookies = append(cookies, cookie.Name+"="+cookie.Value)
|
||
if cookie.Name == "smail_csrf" {
|
||
csrf = cookie.Value
|
||
}
|
||
}
|
||
if len(cookies) == 0 {
|
||
return sessionAuth{}, errors.New("HTTP 通知登录成功但未返回会话 Cookie")
|
||
}
|
||
return sessionAuth{cookie: strings.Join(cookies, "; "), csrf: csrf, origin: notificationOrigin(loginURL)}, nil
|
||
}
|
||
|
||
func notificationOrigin(value string) string {
|
||
parsed, err := url.Parse(value)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String()
|
||
}
|
||
|
||
func (s *Service) loadCredentials(ctx context.Context) (map[string]string, error) {
|
||
if s.config.HTTPAuthMode == "none" || s.config.HTTPAuthMode == "" {
|
||
return nil, nil
|
||
}
|
||
if s.credentials == nil {
|
||
return nil, errors.New("HTTP 通知凭据存储未配置")
|
||
}
|
||
ref := strings.TrimSpace(s.config.HTTPCredentialRef)
|
||
if ref == "" {
|
||
return nil, errors.New("HTTP 通知凭据引用不能为空")
|
||
}
|
||
values, err := s.credentials.Get(ctx, ref)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 HTTP 通知凭据失败:%w", err)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func (s *Service) buildPayload(event Event) (mailPayload, error) {
|
||
fromEmail := strings.TrimSpace(s.config.SenderEmail)
|
||
if fromEmail == "" {
|
||
return mailPayload{}, errors.New("通知发件人地址不能为空")
|
||
}
|
||
parsedSender, err := mail.ParseAddress(fromEmail)
|
||
if err != nil || parsedSender.Address != fromEmail {
|
||
return mailPayload{}, errors.New("通知发件人地址格式无效")
|
||
}
|
||
recipients := make([]address, 0, len(s.config.Recipients))
|
||
for _, value := range s.config.Recipients {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
parsed, err := mail.ParseAddress(value)
|
||
if err != nil || parsed.Address != value {
|
||
return mailPayload{}, fmt.Errorf("通知收件人地址格式无效:%s", value)
|
||
}
|
||
recipients = append(recipients, address{Name: parsed.Name, Email: parsed.Address})
|
||
}
|
||
if len(recipients) == 0 {
|
||
return mailPayload{}, errors.New("通知收件人不能为空")
|
||
}
|
||
status := eventStatus(event.Status)
|
||
domainName := strings.TrimSpace(event.Domain)
|
||
if domainName == "" {
|
||
domainName = strings.TrimSpace(event.DomainID)
|
||
}
|
||
if domainName == "" {
|
||
domainName = "未指定域名"
|
||
}
|
||
title := "证书任务"
|
||
if s.config.EventMode == "unified" {
|
||
title = "证书任务通知"
|
||
} else if event.Type != "" {
|
||
title = eventTypeName(event.Type)
|
||
}
|
||
text := buildText(event, title, status, domainName)
|
||
return mailPayload{
|
||
From: address{Name: s.config.SenderName, Email: fromEmail},
|
||
To: recipients,
|
||
Subject: fmt.Sprintf("[Scert] %s:%s(%s)", title, domainName, status),
|
||
Text: text,
|
||
HTML: "<pre>" + html.EscapeString(text) + "</pre>",
|
||
CC: []address{},
|
||
BCC: []address{},
|
||
Attrs: []any{},
|
||
}, nil
|
||
}
|
||
|
||
func buildText(event Event, title, status, domainName string) string {
|
||
lines := []string{
|
||
"Scert 证书管理服务通知",
|
||
"任务类型:" + title,
|
||
"任务状态:" + status,
|
||
"域名:" + domainName,
|
||
}
|
||
if len(event.Domains) > 0 {
|
||
lines = append(lines, "证书域名:"+strings.Join(event.Domains, "、"))
|
||
}
|
||
if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() {
|
||
lines = append(lines, "证书到期时间:"+event.ExpiresAt.Local().Format("2006-01-02 15:04:05"))
|
||
}
|
||
if event.CertificateSerial != "" {
|
||
lines = append(lines, "证书序列号:"+event.CertificateSerial)
|
||
}
|
||
if event.ManagementURL != "" {
|
||
lines = append(lines, "管理后台:"+event.ManagementURL)
|
||
}
|
||
if event.Deployment.Total > 0 {
|
||
lines = append(lines, fmt.Sprintf("部署结果:总计 %d 台,成功 %d 台,失败 %d 台,状态 %s", event.Deployment.Total, event.Deployment.Success, event.Deployment.Failed, event.Deployment.Status))
|
||
for _, result := range event.Deployment.Results {
|
||
lines = append(lines, fmt.Sprintf("- %s:%s(%s)", result.Server, result.Status, result.Message))
|
||
}
|
||
}
|
||
if event.Error != "" {
|
||
lines = append(lines, "错误信息:"+event.Error)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func eventStatus(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "success":
|
||
return "成功"
|
||
case "failed", "failure":
|
||
return "失败"
|
||
case "error":
|
||
return "错误"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
|
||
func eventTypeName(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "certificate_issue":
|
||
return "证书申请"
|
||
case "certificate_renewal":
|
||
return "证书续期"
|
||
case "certificate_import":
|
||
return "证书导入"
|
||
default:
|
||
return "证书任务"
|
||
}
|
||
}
|
||
|
||
func notificationURL(endpoint, path string) (string, error) {
|
||
base, err := url.Parse(strings.TrimSpace(endpoint))
|
||
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
|
||
return "", errors.New("第三方邮箱 API 地址格式无效")
|
||
}
|
||
path = strings.TrimSpace(path)
|
||
if path == "" || !strings.HasPrefix(path, "/") {
|
||
return "", errors.New("HTTP 通知请求路径必须以 / 开头")
|
||
}
|
||
base.Path = strings.TrimRight(base.Path, "/") + path
|
||
base.RawQuery = ""
|
||
base.Fragment = ""
|
||
return base.String(), nil
|
||
}
|
||
|
||
func isProtectedHeader(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "authorization", "cookie", "origin", "referer", "content-type", "accept", "x-csrf-token", "x-scert-signature", "x-scert-request-id", "x-scert-timestamp":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func credentialValue(values map[string]string, keys ...string) string {
|
||
for _, key := range keys {
|
||
if value := strings.TrimSpace(values[key]); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func sign(secret, timestamp, requestID string, body []byte) string {
|
||
hasher := hmac.New(sha256.New, []byte(secret))
|
||
_, _ = hasher.Write([]byte(timestamp + "." + requestID + "."))
|
||
_, _ = hasher.Write(body)
|
||
return hex.EncodeToString(hasher.Sum(nil))
|
||
}
|
||
|
||
func newRequestID() string {
|
||
var value [16]byte
|
||
if _, err := rand.Read(value[:]); err != nil {
|
||
return ""
|
||
}
|
||
return hex.EncodeToString(value[:])
|
||
}
|
||
|
||
func sleepContext(ctx context.Context, duration time.Duration) error {
|
||
if duration <= 0 {
|
||
return nil
|
||
}
|
||
timer := time.NewTimer(duration)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|