Files
3x-ui-agent/internal/web/service/probenotify/probenotify.go
T

373 lines
11 KiB
Go

// Package probenotify is the item 7 notification engine: the monitor
// hub's notify.rs, re-homed on the panel's own stores (ITEM7-SCOPE
// §3.5/§5.6/B6). Events: a node going offline and returning, a billing
// period's traffic crossing the configured step and the full
// allowance, expiry dates approaching (a daily digest) or rolled
// forward for a node still online past them, and a sign-in to the
// panel.
//
// Channels: Telegram through the panel's existing bot (the
// tgBotToken/tgBotChatId settings — the hub's separate
// notify_telegram_token/chat are deliberately NOT ported, one bot
// configuration serves both) and a new webhook channel (JSON body
// template, custom headers, no redirect following). Delivery is
// queued and retried per channel; nothing here may stall or fail the
// panel flow that raised the event — a dead channel degrades to log
// lines only.
//
// Durable state (offline marks, traffic steps already announced, the
// digest's last-sent date) lives in probe.db's probe_setting through
// the probe package, so a master restart neither replays an offline
// alert nor loses the recovery pairing.
package probenotify
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Note is one alert. Node lists the node names it concerns,
// comma-separated, and is empty for a sign-in.
type Note struct {
Event string
Node string
Title string
Message string
// Unix seconds, stamped when queued: delivery can trail the
// event by minutes while a channel is retried.
Time int64
}
// Numeric settings as (key, min, max, default, the panel's name for
// it) — the hub's NUMBERS table, unchanged.
var numbersTable = []struct {
key, label string
min, max, def int64
}{
// Minutes a node may stay away before it is reported. Capped at
// the flap grace (30 min): a longer grace would outwait a
// flapping node too.
{"notify_grace", "离线宽限期", 1, flapGrace / 60, 3},
// Percent of the allowance that raises the first traffic alert;
// 0 disables traffic alerts.
{"notify_traffic", "流量提醒", 0, 100, 80},
// Days ahead an expiry is listed; 0 disables both expiry
// reminders and renewal notices.
{"notify_expiry", "到期提醒", 0, 365, 7},
}
const (
// How often connectivity and traffic are examined (the sweep
// job's cadence); an offline alert trails the grace period by at
// most this.
sweepInterval = 30 * time.Second
// A node that returned from an absence longer than the grace
// period within flapWindow of going away again is flapping, and
// that absence is reported only once it lasts flapGrace.
flapWindow = 3600 // seconds
flapGrace = 1800 // seconds
// Nodes listed in one alert before the rest are only counted.
listedMax = 20
// onlineWindow is how fresh an agent's last heartbeat must be
// for the sweep to call it online — the B3 public view's rule
// (LastSeen ≤150s against the 60s heartbeat).
onlineWindow = 150 // seconds
defaultBody = `{"event":"{{event}}","node":"{{node}}","title":"{{title}}","message":"{{message}}"}`
defaultText = "{{title}}\n{{message}}"
)
// setting reads one main-store settings value; ok=false when unset.
// The notify keys live in the same KV as every other panel setting
// (hub names kept), read fresh at each use so a panel edit applies
// to the next alert.
var settingFn = func(key string) (string, bool) {
var row model.Setting
if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil {
return "", false
}
return row.Value, true
}
// setSetting writes one main-store settings value (empty deletes the
// row, hub's "empty clears" shape).
var setSettingFn = func(key, value string) error {
db := database.GetDB()
if value == "" {
return db.Where("key = ?", key).Delete(&model.Setting{}).Error
}
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
return db.Create(&model.Setting{Key: key, Value: value}).Error
}
if err != nil {
return err
}
row.Value = value
return db.Save(&row).Error
}
// number reads a numeric notify setting the hub way: parse, range
// check, default on anything else.
func number(key string) int64 {
for _, n := range numbersTable {
if n.key == key {
if v, ok := settingFn(key); ok {
if parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && parsed >= n.min && parsed <= n.max {
return parsed
}
}
return n.def
}
}
return 0
}
// templateSetting is a template setting, where empty means default.
func templateSetting(key, def string) string {
if v, ok := settingFn(key); ok && v != "" {
return v
}
return def
}
// loginEnabled reports the notify_login switch (default on, hub).
func loginEnabled() bool {
v, _ := settingFn("notify_login")
return v != "off"
}
// siteName is the {{site}} placeholder's value: the public page's
// site_name setting, "Monitor" by default (hub site_name()).
func siteName() string {
if v, ok := settingFn("site_name"); ok && v != "" {
return v
}
return "Monitor"
}
// SettingError reports why a notification setting value cannot be
// stored, or "" when it can — the hub's setting_error for the notify_*
// keys, minus the Telegram token/chat pair (the panel bot owns those
// credentials; see the package comment) and with the same texts.
func SettingError(key, value string) string {
for _, n := range numbersTable {
if n.key == key {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < n.min || parsed > n.max {
return fmt.Sprintf("%s要填 %d 到 %d 之间的整数", n.label, n.min, n.max)
}
return ""
}
}
switch key {
case "notify_login":
if value != "on" && value != "off" {
return "登录提醒只能是 on 或 off"
}
case "notify_webhook_headers":
_, err := parseHeaders(value)
return err
case "notify_telegram_text", "notify_webhook_url", "notify_webhook_body":
// Empty clears the channel field, or restores a template's
// default.
if value == "" {
return ""
}
switch key {
case "notify_telegram_text":
// Any text goes; placeholders substitute at send time.
case "notify_webhook_url":
if !validWebhookURL(value) {
return "Webhook URL 必须以 http:// 或 https:// 开头"
}
case "notify_webhook_body":
var v any
if err := json.Unmarshal([]byte(render(value, sampleNote(), "s\"i\\te", true)), &v); err != nil {
return "Webhook 请求体填入内容后必须是合法的 JSON,占位符要放在引号里"
}
}
default:
return fmt.Sprintf("没有这个设置项:%s", key)
}
return ""
}
func validWebhookURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
// sampleNote carries values that exercise every escape the body
// template must survive (hub sample()).
func sampleNote() Note {
return Note{
Event: "test",
Node: "a\"b\\c",
Title: "{{message}}",
Message: "line\nline",
}
}
// parseHeaders parses "Name: value", one per line. Values are never
// echoed in an error: they are typically the credential.
func parseHeaders(text string) ([][2]string, string) {
var out [][2]string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, value, found := strings.Cut(line, ":")
if !found {
return nil, "Webhook 请求头每行要写成「名称: 值」"
}
name = strings.TrimSpace(name)
value = strings.TrimSpace(value)
if !validHeaderName(name) {
return nil, fmt.Sprintf("Webhook 请求头名称 %q 不合法", name)
}
if strings.ContainsAny(value, "\r\n") {
return nil, fmt.Sprintf("Webhook 请求头 %s 的值不合法", name)
}
out = append(out, [2]string{name, value})
}
return out, ""
}
// validHeaderName is RFC 7230 token.
func validHeaderName(name string) bool {
if name == "" {
return false
}
for _, r := range name {
if r <= ' ' || r >= 0x7f || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) {
return false
}
}
return true
}
// render substitutes the placeholders in a single pass, JSON-escaping
// each value when jsonEsc is set so it is valid inside a string
// literal. A second pass would also substitute placeholders contained
// in the inserted values, such as a node name (hub render()).
func render(tmpl string, note Note, site string, jsonEsc bool) string {
fields := []struct{ key, value string }{
{"{{event}}", note.Event},
{"{{node}}", note.Node},
{"{{title}}", note.Title},
{"{{message}}", note.Message},
{"{{site}}", site},
{"{{time}}", clock(note.Time)},
}
var out strings.Builder
out.Grow(len(tmpl))
rest := tmpl
for {
at := strings.Index(rest, "{{")
if at < 0 {
out.WriteString(rest)
return out.String()
}
out.WriteString(rest[:at])
rest = rest[at:]
matched := false
for _, f := range fields {
if strings.HasPrefix(rest, f.key) {
if jsonEsc {
quoted, _ := json.Marshal(f.value)
out.Write(quoted[1 : len(quoted)-1])
} else {
out.WriteString(f.value)
}
rest = rest[len(f.key):]
matched = true
break
}
}
if !matched {
out.WriteString("{{")
rest = rest[2:]
}
}
}
// batch builds one alert for any number of nodes: a list in the
// message, the names in Note.Node. ok is false when there is nothing
// to say (hub batch()).
func batch(event, mark, what string, items []batchItem) (Note, bool) {
if len(items) == 0 {
return Note{}, false
}
var title, message string
if len(items) == 1 {
title = fmt.Sprintf("%s %s %s", mark, items[0].name, what)
message = items[0].detail
} else {
lines := make([]string, 0, listedMax+1)
for i, it := range items {
if i >= listedMax {
break
}
lines = append(lines, fmt.Sprintf("%s · %s", it.name, it.detail))
}
if len(items) > listedMax {
lines = append(lines, fmt.Sprintf("……另外 %d 台", len(items)-listedMax))
}
title = fmt.Sprintf("%s %d 台节点%s", mark, len(items), what)
message = strings.Join(lines, "\n")
}
names := make([]string, len(items))
for i, it := range items {
names[i] = it.name
}
return Note{Event: event, Node: strings.Join(names, ", "), Title: title, Message: message}, true
}
type batchItem struct {
name, detail string
}
// span renders a duration the hub way ("3 分钟" / "2 小时 5 分钟" /
// "1 天 2 小时").
func span(seconds int64) string {
if seconds < 0 {
seconds = 0
}
minutes := seconds / 60
d, h, m := minutes/1440, minutes/60%24, minutes%60
switch {
case d == 0 && h == 0:
return fmt.Sprintf("%d 分钟", m)
case d == 0:
return fmt.Sprintf("%d 小时 %d 分钟", h, m)
default:
return fmt.Sprintf("%d 天 %d 小时", d, h)
}
}
// clock formats a unix stamp in local time with its offset: a master
// in a container commonly runs on UTC while its operator reads local
// time (hub clock()).
func clock(ts int64) string {
if ts == 0 {
return ""
}
return time.Unix(ts, 0).Format("01-02 15:04 -07:00")
}
// gib renders bytes as GiB, labelled GB as in the panel (hub gib()).
func gib(bytes int64) string {
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(int64(1)<<30))
}