Scert/backend/internal/notification/service.go

540 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package notification
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"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
smtpTLSConfig func(string) *tls.Config
}
// New 创建 HTTP 通知服务。SMTP 配置暂保留为兼容字段,当前发送器只执行 HTTP 通知。
func New(cfg config.NotificationConfig, credentials CredentialReader) *Service {
service := &Service{
config: cfg,
credentials: credentials,
client: &http.Client{},
now: time.Now,
requestID: newRequestID,
sleep: sleepContext,
}
service.smtpTLSConfig = func(host string) *tls.Config {
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
}
return service
}
// Send 按配置发送通知;未启用任何通知通道时直接返回成功。
func (s *Service) sendHTTP(ctx context.Context, event Event) error {
if s == nil {
return errors.New("通知服务未初始化")
}
if !s.config.HTTPEnabled {
return nil
}
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 == "" || 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:%s", *envelope.ErrorNo, strings.TrimSpace(envelope.ErrorMsg))
}
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 "management_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
}
}