Files
Havline/internal/notify/push.go
T
mrlee 862442e77f feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回

- agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编

- 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值

- 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知

- API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
2026-09-24 20:50:17 +08:00

220 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
switch cfg.Webhook.Provider {
case WebhookBark:
return sendBark(ctx, client, cfg, content.PushTitle, content.barkMessage())
case WebhookNtfy:
return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
case WebhookGotify:
return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
case WebhookWecom:
return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody)
case WebhookDingtalk:
return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody)
case WebhookFeishu:
return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody)
case WebhookCustom:
return sendCustomWebhook(ctx, client, cfg, content)
default:
return fmt.Errorf("不支持的 Webhook 预设")
}
}
func webhookSecret(cfg RuntimeConfig) string {
if strings.TrimSpace(cfg.WebhookSecret) != "" {
return strings.TrimSpace(cfg.WebhookSecret)
}
return strings.TrimSpace(cfg.Webhook.Key)
}
func sendBark(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
key := webhookSecret(cfg)
if key == "" {
return fmt.Errorf("Bark Device Key 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultBarkServer
}
endpoint := fmt.Sprintf("%s/%s/%s/%s", server, url.PathEscape(key), url.PathEscape(title), url.PathEscape(message))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
return doRequest(client, req)
}
func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
topic := strings.TrimSpace(cfg.Webhook.Topic)
if topic == "" {
return fmt.Errorf("ntfy Topic 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultNtfyServer
}
endpoint := joinURL(server, topic)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
if err != nil {
return err
}
req.Header.Set("Title", title)
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
if token := webhookSecret(cfg); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return doRequest(client, req)
}
func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
token := webhookSecret(cfg)
if token == "" {
return fmt.Errorf("Gotify App Token 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
return fmt.Errorf("Gotify 服务地址未配置")
}
endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
return postJSON(ctx, client, endpoint, map[string]any{
"title": title,
"message": message,
"priority": 5,
})
}
// 企业微信群机器人:POST /cgi-bin/webhook/send?key=<key>,markdown 正文
func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
key := webhookSecret(cfg)
if key == "" {
return fmt.Errorf("企业微信机器人 Key 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultWecomServer
}
endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key)
return postJSON(ctx, client, endpoint, map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"content": markdownBody(title, message)},
})
}
// 钉钉群机器人:POST /robot/send?access_token=<key>。
// 正文固定带 “Fonu · ” 前缀,机器人用「自定义关键词 = Fonu」的安全设置即可命中。
func sendDingtalk(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
key := webhookSecret(cfg)
if key == "" {
return fmt.Errorf("钉钉机器人 Access Token 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultDingtalkServer
}
titled := "Fonu · " + title
endpoint := joinURL(server, "robot", "send") + "?access_token=" + url.QueryEscape(key)
return postJSON(ctx, client, endpoint, map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": titled, "text": markdownBody(titled, message)},
})
}
// 飞书自定义机器人:POST /open-apis/bot/v2/hook/<key>,纯文本消息
func sendFeishu(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
key := webhookSecret(cfg)
if key == "" {
return fmt.Errorf("飞书机器人 Key 未配置")
}
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultFeishuServer
}
endpoint := joinURL(server, "open-apis", "bot", "v2", "hook", key)
return postJSON(ctx, client, endpoint, map[string]any{
"msg_type": "text",
"content": map[string]string{"text": title + "\n" + message},
})
}
// markdownBody 拼出国内 IM 机器人都能渲染的 markdown(企业微信 / 钉钉都认 ## 标题 + 正文)
func markdownBody(title, message string) string {
return "## " + title + "\n" + message
}
func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
target := strings.TrimSpace(cfg.Webhook.URL)
if target == "" {
return fmt.Errorf("Webhook URL 未配置")
}
return postJSON(ctx, client, target, content.webhookPayload())
}
// postJSON 发一个 JSON POST:各国内 IM 机器人都是这个形状,只是 URL 与 payload 不同。
func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return doRequest(client, req)
}
func doRequest(client *http.Client, req *http.Request) error {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if len(body) > 0 {
return fmt.Errorf("请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return fmt.Errorf("请求失败 (%d)", resp.StatusCode)
}
return nil
}
func normalizeServerURL(server string) string {
server = strings.TrimSpace(server)
server = strings.TrimRight(server, "/")
if server == "" {
return ""
}
if !strings.Contains(server, "://") {
server = "https://" + server
}
return server
}
func joinURL(base string, parts ...string) string {
base = strings.TrimRight(normalizeServerURL(base), "/")
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
base += "/" + part
}
}
return base
}