发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user