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