Files
Wang Defa dbba1f4905
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
2026-07-10 17:38:34 +08:00

310 lines
10 KiB
Go

package service
import (
"context"
"fmt"
"strconv"
"strings"
)
// 通知渠道配置键,前缀 notify_ch_<type>_;标注「密文」的键 AES-GCM 加密落库。
const (
settingChWebhookEnabled = "notify_ch_webhook_enabled" // "1"/"0"
settingChWebhookURL = "notify_ch_webhook_url"
settingChWebhookBodyTpl = "notify_ch_webhook_body_tpl"
settingChNtfyEnabled = "notify_ch_ntfy_enabled"
settingChNtfyServer = "notify_ch_ntfy_server"
settingChNtfyTopic = "notify_ch_ntfy_topic"
settingChNtfyToken = "notify_ch_ntfy_token" // 密文
settingChBarkEnabled = "notify_ch_bark_enabled"
settingChBarkServer = "notify_ch_bark_server"
settingChBarkDeviceKey = "notify_ch_bark_device_key" // 密文
settingChSMTPEnabled = "notify_ch_smtp_enabled"
settingChSMTPHost = "notify_ch_smtp_host"
settingChSMTPPort = "notify_ch_smtp_port"
settingChSMTPUsername = "notify_ch_smtp_username"
settingChSMTPPassword = "notify_ch_smtp_password" // 密文
settingChSMTPFrom = "notify_ch_smtp_from"
settingChSMTPTo = "notify_ch_smtp_to"
)
// ErrUnknownNotifyChannel 标记未知渠道类型,api 层映射 400。
var ErrUnknownNotifyChannel = fmt.Errorf("未知通知渠道类型")
// NotifyChannelView 是单渠道的脱敏视图:密文字段只回是否已配置与尾 4 位。
type NotifyChannelView struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
// webhook
URL string `json:"url,omitempty"`
BodyTemplate string `json:"bodyTemplate,omitempty"`
// ntfy / bark 共用 Server
Server string `json:"server,omitempty"`
Topic string `json:"topic,omitempty"`
// smtp
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
// 密文字段状态(ntfy token / bark device key / smtp password)
SecretSet bool `json:"secretSet"`
SecretTail string `json:"secretTail,omitempty"`
}
// UpdateNotifyChannelInput 是单渠道更新入参;Secret 为 nil 沿用已存密文,空串清除。
type UpdateNotifyChannelInput struct {
Enabled bool
URL string
BodyTemplate string
Server string
Topic string
Host string
Port int
Username string
From string
To string
Secret *string
}
// notifyChannelKeys 返回渠道的(启用键, 明文键列表, 密文键);未知类型返回启用键空串。
func notifyChannelKeys(chType string) (enabled string, plain []string, secret string) {
switch chType {
case ChannelWebhook:
return settingChWebhookEnabled, []string{settingChWebhookURL, settingChWebhookBodyTpl}, ""
case ChannelNtfy:
return settingChNtfyEnabled, []string{settingChNtfyServer, settingChNtfyTopic}, settingChNtfyToken
case ChannelBark:
return settingChBarkEnabled, []string{settingChBarkServer}, settingChBarkDeviceKey
case ChannelSMTP:
return settingChSMTPEnabled, []string{settingChSMTPHost, settingChSMTPPort,
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo}, settingChSMTPPassword
}
return "", nil, ""
}
// NotifyChannels 返回全部新增渠道的脱敏视图(不含 telegram,其沿用独立接口)。
func (s *SettingService) NotifyChannels(ctx context.Context) ([]NotifyChannelView, error) {
out := make([]NotifyChannelView, 0, len(NotifyChannelTypes))
for _, t := range NotifyChannelTypes {
v, err := s.notifyChannelView(ctx, t)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// notifyChannelView 组装单渠道脱敏视图。
func (s *SettingService) notifyChannelView(ctx context.Context, chType string) (NotifyChannelView, error) {
enabledKey, plain, secretKey := notifyChannelKeys(chType)
vals, err := s.getMany(ctx, append(plain, enabledKey)...)
if err != nil {
return NotifyChannelView{}, err
}
view := NotifyChannelView{Type: chType, Enabled: vals[enabledKey] == "1"}
fillChannelView(&view, chType, vals)
if secretKey == "" {
return view, nil
}
secret, err := s.decryptSetting(ctx, secretKey)
if err != nil {
return NotifyChannelView{}, err
}
view.SecretSet = secret != ""
view.SecretTail = tokenTail(secret)
return view, nil
}
// fillChannelView 把明文键值填入视图对应字段。
func fillChannelView(v *NotifyChannelView, chType string, vals map[string]string) {
switch chType {
case ChannelWebhook:
v.URL, v.BodyTemplate = vals[settingChWebhookURL], vals[settingChWebhookBodyTpl]
case ChannelNtfy:
v.Server, v.Topic = vals[settingChNtfyServer], vals[settingChNtfyTopic]
case ChannelBark:
v.Server = vals[settingChBarkServer]
case ChannelSMTP:
v.Host, v.Username = vals[settingChSMTPHost], vals[settingChSMTPUsername]
v.From, v.To = vals[settingChSMTPFrom], vals[settingChSMTPTo]
v.Port, _ = strconv.Atoi(vals[settingChSMTPPort])
}
}
// UpdateNotifyChannel 保存单渠道配置;字段先经 validateChannelInput 校验。
func (s *SettingService) UpdateNotifyChannel(ctx context.Context, chType string, in UpdateNotifyChannelInput) error {
enabledKey, _, secretKey := notifyChannelKeys(chType)
if enabledKey == "" {
return ErrUnknownNotifyChannel
}
if err := validateChannelInput(chType, in); err != nil {
return err
}
pairs := channelPlainPairs(chType, in)
pairs[enabledKey] = boolSetting(in.Enabled)
for k, v := range pairs {
if err := s.set(ctx, k, v); err != nil {
return err
}
}
if secretKey == "" || in.Secret == nil {
return nil
}
return s.encryptSetting(ctx, secretKey, *in.Secret)
}
// channelPlainPairs 返回渠道明文键与入参值的对应关系。
func channelPlainPairs(chType string, in UpdateNotifyChannelInput) map[string]string {
switch chType {
case ChannelWebhook:
return map[string]string{settingChWebhookURL: in.URL, settingChWebhookBodyTpl: in.BodyTemplate}
case ChannelNtfy:
return map[string]string{settingChNtfyServer: in.Server, settingChNtfyTopic: in.Topic}
case ChannelBark:
return map[string]string{settingChBarkServer: in.Server}
case ChannelSMTP:
return map[string]string{settingChSMTPHost: in.Host, settingChSMTPPort: strconv.Itoa(in.Port),
settingChSMTPUsername: in.Username, settingChSMTPFrom: in.From, settingChSMTPTo: in.To}
}
return nil
}
// validateChannelInput 按渠道校验入参;仅在启用时强校验必填,便于「先存草稿后启用」。
func validateChannelInput(chType string, in UpdateNotifyChannelInput) error {
if !in.Enabled {
return validateChannelURLs(chType, in)
}
switch chType {
case ChannelWebhook:
if strings.TrimSpace(in.URL) == "" {
return fmt.Errorf("启用 Webhook 须填写 URL")
}
case ChannelNtfy:
if strings.TrimSpace(in.Topic) == "" {
return fmt.Errorf("启用 ntfy 须填写 topic")
}
case ChannelSMTP:
if in.Host == "" || in.Port <= 0 || in.Port > 65535 || in.From == "" || in.To == "" {
return fmt.Errorf("启用 SMTP 须填写主机、端口(1-65535)、发件人与收件人")
}
}
return validateChannelURLs(chType, in)
}
// validateChannelURLs 校验渠道内的 URL 形字段(非空才校验)。
func validateChannelURLs(chType string, in UpdateNotifyChannelInput) error {
if chType == ChannelWebhook && in.URL != "" {
return validNotifyURL(in.URL)
}
if (chType == ChannelNtfy || chType == ChannelBark) && in.Server != "" {
return validNotifyURL(in.Server)
}
return nil
}
// boolSetting 把布尔序列化为设置值 "1"/"0"。
func boolSetting(b bool) string {
if b {
return "1"
}
return "0"
}
// encryptSetting 加密保存设置值;空串直接落空值表示清除。
func (s *SettingService) encryptSetting(ctx context.Context, key, value string) error {
if value == "" {
return s.set(ctx, key, "")
}
enc, err := s.cipher.EncryptString(value)
if err != nil {
return fmt.Errorf("encrypt setting %s: %w", key, err)
}
return s.set(ctx, key, enc)
}
// decryptSetting 读取并解密设置值;未配置返回空串。
func (s *SettingService) decryptSetting(ctx context.Context, key string) (string, error) {
enc, err := s.get(ctx, key)
if err != nil || enc == "" {
return "", err
}
val, err := s.cipher.DecryptString(enc)
if err != nil {
return "", fmt.Errorf("decrypt setting %s: %w", key, err)
}
return val, nil
}
// webhookChannel 读取解密后的 Webhook 配置,供发送端消费。
func (s *SettingService) webhookChannel(ctx context.Context) (WebhookChannel, error) {
vals, err := s.getMany(ctx, settingChWebhookEnabled, settingChWebhookURL, settingChWebhookBodyTpl)
if err != nil {
return WebhookChannel{}, err
}
return WebhookChannel{
Enabled: vals[settingChWebhookEnabled] == "1",
URL: vals[settingChWebhookURL],
BodyTemplate: vals[settingChWebhookBodyTpl],
}, nil
}
// ntfyChannel 读取解密后的 ntfy 配置。
func (s *SettingService) ntfyChannel(ctx context.Context) (NtfyChannel, error) {
vals, err := s.getMany(ctx, settingChNtfyEnabled, settingChNtfyServer, settingChNtfyTopic)
if err != nil {
return NtfyChannel{}, err
}
token, err := s.decryptSetting(ctx, settingChNtfyToken)
if err != nil {
return NtfyChannel{}, err
}
return NtfyChannel{
Enabled: vals[settingChNtfyEnabled] == "1",
Server: vals[settingChNtfyServer],
Topic: vals[settingChNtfyTopic],
Token: token,
}, nil
}
// barkChannel 读取解密后的 Bark 配置。
func (s *SettingService) barkChannel(ctx context.Context) (BarkChannel, error) {
vals, err := s.getMany(ctx, settingChBarkEnabled, settingChBarkServer)
if err != nil {
return BarkChannel{}, err
}
key, err := s.decryptSetting(ctx, settingChBarkDeviceKey)
if err != nil {
return BarkChannel{}, err
}
return BarkChannel{
Enabled: vals[settingChBarkEnabled] == "1",
Server: vals[settingChBarkServer],
DeviceKey: key,
}, nil
}
// smtpChannel 读取解密后的 SMTP 配置。
func (s *SettingService) smtpChannel(ctx context.Context) (SMTPChannel, error) {
vals, err := s.getMany(ctx, settingChSMTPEnabled, settingChSMTPHost, settingChSMTPPort,
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo)
if err != nil {
return SMTPChannel{}, err
}
password, err := s.decryptSetting(ctx, settingChSMTPPassword)
if err != nil {
return SMTPChannel{}, err
}
port, _ := strconv.Atoi(vals[settingChSMTPPort])
return SMTPChannel{
Enabled: vals[settingChSMTPEnabled] == "1",
Host: vals[settingChSMTPHost],
Port: port,
Username: vals[settingChSMTPUsername],
Password: password,
From: vals[settingChSMTPFrom],
To: vals[settingChSMTPTo],
}, nil
}