Files
oci-portal/internal/service/notifychannel.go
T
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

219 lines
6.6 KiB
Go

package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"mime"
"net"
"net/http"
"net/smtp"
"net/url"
"strings"
)
// 通知渠道类型;telegram 走既有独立路径,不纳入此枚举。
const (
ChannelWebhook = "webhook"
ChannelNtfy = "ntfy"
ChannelBark = "bark"
ChannelSMTP = "smtp"
)
// NotifyChannelTypes 是新增渠道的稳定顺序(视图输出与遍历发送共用)。
var NotifyChannelTypes = []string{ChannelWebhook, ChannelNtfy, ChannelBark, ChannelSMTP}
// notifyMsg 是渲染完成的渠道无关载荷:Title 取通知类型显示名,Text 为模板渲染文本。
type notifyMsg struct {
Title string
Text string
}
// defaultWebhookBody 是通用 Webhook 的缺省 body 模板(兼容 Slack incoming webhook)。
const defaultWebhookBody = `{"text": "{{title}}\n{{text}}"}`
// 渠道服务端缺省地址。
const (
defaultNtfyServer = "https://ntfy.sh"
defaultBarkServer = "https://api.day.app"
)
// WebhookChannel 是通用 Webhook 渠道配置;BodyTemplate 空串按缺省模板发送。
type WebhookChannel struct {
Enabled bool
URL string
BodyTemplate string
}
// NtfyChannel 是 ntfy 渠道配置;Server 空串用官方 ntfy.sh,Token 可选。
type NtfyChannel struct {
Enabled bool
Server string
Topic string
Token string
}
// BarkChannel 是 Bark(iOS)渠道配置;Server 空串用官方 api.day.app。
type BarkChannel struct {
Enabled bool
Server string
DeviceKey string
}
// SMTPChannel 是邮件渠道配置;465 端口走隐式 TLS,其余端口 STARTTLS,Username 空则免认证。
type SMTPChannel struct {
Enabled bool
Host string
Port int
Username string
Password string
From string
To string
}
// validNotifyURL 校验渠道出站地址:仅接受 http/https 且 host 非空。
func validNotifyURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("地址须为 http(s):// 开头的合法 URL")
}
return nil
}
// renderWebhookBody 渲染 body 模板:占位值先做 JSON 字符串转义(去掉包裹引号)再替换,
// 保证换行/引号嵌入 JSON 字符串后仍合法。
func renderWebhookBody(tpl string, msg notifyMsg) string {
if strings.TrimSpace(tpl) == "" {
tpl = defaultWebhookBody
}
r := strings.NewReplacer("{{title}}", jsonEscape(msg.Title), "{{text}}", jsonEscape(msg.Text))
return r.Replace(tpl)
}
// jsonEscape 返回 s 作为 JSON 字符串字面量的内容(不含首尾引号)。
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}
// sendWebhook 向自定义 endpoint POST 渲染后的 JSON body。
func sendWebhook(ctx context.Context, client *http.Client, cfg WebhookChannel, msg notifyMsg) error {
body := renderWebhookBody(cfg.BodyTemplate, msg)
return postJSON(ctx, client, cfg.URL, "", []byte(body))
}
// sendNtfy 以 JSON publish 模式向 ntfy 服务端根路径发布(UTF-8 标题无兼容问题)。
func sendNtfy(ctx context.Context, client *http.Client, cfg NtfyChannel, msg notifyMsg) error {
server := cfg.Server
if server == "" {
server = defaultNtfyServer
}
body, err := json.Marshal(map[string]string{"topic": cfg.Topic, "title": msg.Title, "message": msg.Text})
if err != nil {
return fmt.Errorf("ntfy send: %w", err)
}
return postJSON(ctx, client, strings.TrimRight(server, "/"), cfg.Token, body)
}
// sendBark 调用 Bark v2 push 接口。
func sendBark(ctx context.Context, client *http.Client, cfg BarkChannel, msg notifyMsg) error {
server := cfg.Server
if server == "" {
server = defaultBarkServer
}
body, err := json.Marshal(map[string]string{"title": msg.Title, "body": msg.Text, "device_key": cfg.DeviceKey})
if err != nil {
return fmt.Errorf("bark send: %w", err)
}
return postJSON(ctx, client, strings.TrimRight(server, "/")+"/push", "", body)
}
// postJSON 发送 JSON POST;token 非空时附 Bearer 认证,非 2xx 一律视为失败。
func postJSON(ctx context.Context, client *http.Client, u, token string, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return sanitizeURLError(err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return sanitizeURLError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d", resp.StatusCode)
}
return nil
}
// sendSMTP 组装邮件报文并投递;主题按 RFC2047 编码,正文 UTF-8 纯文本。
func sendSMTP(ctx context.Context, cfg SMTPChannel, msg notifyMsg) error {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", cfg.From)
fmt.Fprintf(&b, "To: %s\r\n", cfg.To)
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", msg.Title))
b.WriteString("MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
b.WriteString(strings.ReplaceAll(msg.Text, "\n", "\r\n"))
return smtpDeliver(ctx, cfg, []byte(b.String()))
}
// smtpDeliver 建立 SMTP 会话:465 端口隐式 TLS,其余端口连通后尝试 STARTTLS。
func smtpDeliver(ctx context.Context, cfg SMTPChannel, mail []byte) error {
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
d := &net.Dialer{}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
if cfg.Port == 465 {
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp handshake: %w", err)
}
defer c.Close()
return smtpWrite(c, cfg, mail)
}
// smtpWrite 在既有会话上完成 STARTTLS/认证/收发件与报文写入。
func smtpWrite(c *smtp.Client, cfg SMTPChannel, mail []byte) error {
if cfg.Port != 465 {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
}
if cfg.Username != "" {
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(cfg.From); err != nil {
return fmt.Errorf("smtp from: %w", err)
}
if err := c.Rcpt(cfg.To); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(mail); err != nil {
w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close: %w", err)
}
return c.Quit()
}