Scert/backend/internal/bootstrap/notification.go

210 lines
9.0 KiB
Go

package bootstrap
import (
"context"
"errors"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
// NotificationInput 不接受客户端指定凭据引用;新密码只写入加密存储。
// 省略整个通知对象时保留原配置,提交对象时按完整表单应用配置。
type NotificationInput struct {
HTTPEnabled bool `json:"http_enabled"`
SMTPEnabled bool `json:"smtp_enabled"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HTTPPath string `json:"http_path"`
HTTPLoginPath string `json:"http_login_path"`
HTTPAuthMode string `json:"http_auth_mode"`
HTTPCredentials map[string]string `json:"http_credentials"`
HTTPHeaders map[string]string `json:"http_headers"`
EventMode string `json:"event_mode"`
IncludeDeploymentDetails bool `json:"include_deployment_details"`
IncludeCertificateSerial bool `json:"include_certificate_serial"`
IncludeManagementURL bool `json:"include_management_url"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
Recipients []string `json:"recipients"`
TimeoutSecs int `json:"timeout_secs"`
RetryCount int `json:"retry_count"`
RetryIntervalsSecs []int `json:"retry_intervals_secs"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
SMTPMode string `json:"smtp_mode"`
SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
}
func (completer *Completer) prepareNotification(ctx context.Context, current config.NotificationConfig, input *NotificationInput) (config.NotificationConfig, func() error, error) {
if input == nil {
return current, nil, nil
}
candidate := config.NotificationConfig{
HTTPEnabled: input.HTTPEnabled, SMTPEnabled: input.SMTPEnabled,
Provider: strings.TrimSpace(input.Provider), Endpoint: strings.TrimSpace(input.Endpoint),
HTTPPath: strings.TrimSpace(input.HTTPPath), HTTPLoginPath: strings.TrimSpace(input.HTTPLoginPath),
HTTPAuthMode: strings.ToLower(strings.TrimSpace(input.HTTPAuthMode)), HTTPHeaders: input.HTTPHeaders,
EventMode: strings.TrimSpace(input.EventMode), IncludeDeploymentDetails: input.IncludeDeploymentDetails,
IncludeCertificateSerial: input.IncludeCertificateSerial, IncludeManagementURL: input.IncludeManagementURL,
SenderName: strings.TrimSpace(input.SenderName), SenderEmail: strings.TrimSpace(input.SenderEmail),
Recipients: input.Recipients, TimeoutSecs: input.TimeoutSecs,
RetryCount: input.RetryCount, RetryIntervalsSecs: input.RetryIntervalsSecs,
SMTPHost: strings.TrimSpace(input.SMTPHost), SMTPPort: input.SMTPPort,
SMTPUsername: strings.TrimSpace(input.SMTPUsername), SMTPMode: strings.ToLower(strings.TrimSpace(input.SMTPMode)),
SMTPAllowPlaintextAuth: input.SMTPAllowPlaintextAuth,
}
if candidate.HTTPAuthMode == "" || !candidate.HTTPEnabled {
candidate.HTTPAuthMode = "none"
}
if candidate.HTTPPath == "" {
candidate.HTTPPath = "/api/email/send"
}
if candidate.HTTPLoginPath == "" {
candidate.HTTPLoginPath = "/api/login"
}
if candidate.EventMode == "" {
candidate.EventMode = "detailed"
}
if candidate.SenderName == "" {
candidate.SenderName = "Scert"
}
if candidate.TimeoutSecs == 0 {
candidate.TimeoutSecs = 10
}
if candidate.SMTPPort == 0 {
candidate.SMTPPort = 587
}
if candidate.SMTPMode == "" {
candidate.SMTPMode = "starttls"
}
if candidate.RetryCount < 0 || len(candidate.RetryIntervalsSecs) != candidate.RetryCount || candidate.TimeoutSecs < 1 {
return candidate, nil, completeError(CompleteErrorInput, "通知超时或重试参数无效", nil)
}
if candidate.RetryCount == 0 {
candidate.RetryIntervalsSecs = []int{}
}
if candidate.HTTPEnabled && candidate.Endpoint == "" {
return candidate, nil, completeError(CompleteErrorInput, "启用 HTTP 通知时必须配置服务地址", nil)
}
if candidate.SMTPEnabled && (candidate.SMTPHost == "" || candidate.SMTPUsername == "" || input.SMTPPassword == "" ||
(candidate.SMTPMode == "plain" && !candidate.SMTPAllowPlaintextAuth)) {
return candidate, nil, completeError(CompleteErrorInput, "SMTP 通知主机、账号、密码或连接安全参数无效", nil)
}
credentials := make(map[string]map[string]string)
if candidate.SMTPEnabled {
name, email, err := candidate.SenderIdentity()
if err != nil {
return candidate, nil, completeError(CompleteErrorInput, "SMTP 发件账号无效,请使用 Smail 系统通知页提供的完整用户名", err)
}
candidate.SenderName, candidate.SenderEmail = name, email
}
if candidate.HTTPEnabled && candidate.HTTPAuthMode != "none" {
values, err := notificationCredentials(candidate.HTTPAuthMode, input.HTTPCredentials)
if err != nil {
return candidate, nil, completeError(CompleteErrorInput, "HTTP 通知认证凭据无效", err)
}
credentials["http"] = values
}
if candidate.SMTPEnabled {
credentials["smtp"] = map[string]string{"username": candidate.SMTPUsername, "password": input.SMTPPassword}
}
if len(credentials) == 0 {
return candidate, nil, nil
}
store, ok := completer.credentials.(DatabaseSecretStore)
if !ok {
return candidate, nil, completeError(CompleteErrorInternal, "通知加密凭据存储尚未就绪", nil)
}
references := make([]string, 0, len(credentials))
cleanup := sync.OnceValue(func() error {
var failures []error
for _, reference := range references {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
failures = append(failures, store.Delete(cleanupCtx, reference))
cancel()
}
return errors.Join(failures...)
})
for _, kind := range []string{"http", "smtp"} {
values, exists := credentials[kind]
if !exists {
continue
}
token, err := randomToken(24)
if err != nil {
return candidate, cleanup, completeError(CompleteErrorInternal, "生成通知凭据引用失败", err)
}
reference := "secret://notification/" + token
references = append(references, reference)
if err := store.Put(ctx, reference, values); err != nil {
return candidate, cleanup, completeError(CompleteErrorDependency, "加密保存通知凭据失败", err)
}
if kind == "http" {
candidate.HTTPCredentialRef = reference
} else {
candidate.SMTPCredentialRef = reference
}
}
return candidate, cleanup, nil
}
func notificationCredentials(mode string, input map[string]string) (map[string]string, error) {
first := func(keys ...string) string {
for _, key := range keys {
if value := input[key]; strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
switch mode {
case "bearer":
if value := first("token", "access_token"); value != "" {
return map[string]string{"token": value}, nil
}
case "hmac_sha256":
if value := first("secret", "hmac_secret", "key", "token"); value != "" {
return map[string]string{"secret": value}, nil
}
case "session":
if account := first("account", "username", "identifier"); account != "" && input["password"] != "" {
return map[string]string{"account": account, "password": input["password"]}, nil
}
}
return nil, errors.New("通知认证方式或必填凭据无效")
}
func applyNotificationInput(input *setup.Input, notification config.NotificationConfig) {
input.EmailHTTPEnabled = notification.HTTPEnabled
input.EmailSMTPEnabled = notification.SMTPEnabled
input.EmailSMTPHost = notification.SMTPHost
input.EmailSMTPPort = notification.SMTPPort
input.EmailSMTPUsername = notification.SMTPUsername
input.EmailSMTPCredentialRef = notification.SMTPCredentialRef
input.EmailSMTPMode = notification.SMTPMode
input.EmailSMTPAllowPlaintextAuth = notification.SMTPAllowPlaintextAuth
input.EmailProvider = notification.Provider
input.EmailEndpoint = notification.Endpoint
input.EmailHTTPPath = notification.HTTPPath
input.EmailHTTPLoginPath = notification.HTTPLoginPath
input.EmailHTTPAuthMode = notification.HTTPAuthMode
input.EmailHTTPCredentialRef = notification.HTTPCredentialRef
input.EmailHTTPHeaders = notification.HTTPHeaders
input.EmailEventMode = notification.EventMode
input.EmailIncludeDeploymentDetails = notification.IncludeDeploymentDetails
input.EmailIncludeCertificateSerial = notification.IncludeCertificateSerial
input.EmailIncludeManagementURL = notification.IncludeManagementURL
input.EmailSenderName = notification.SenderName
input.EmailSenderEmail = notification.SenderEmail
input.EmailRecipients = notification.Recipients
input.EmailTimeoutSecs = notification.TimeoutSecs
input.EmailRetryCount = notification.RetryCount
input.EmailRetryIntervalsSecs = notification.RetryIntervalsSecs
}